diff --git a/src/utils/plc-logs.ts b/src/utils/plc-logs.ts new file mode 100644 index 0000000..842ca12 --- /dev/null +++ b/src/utils/plc-logs.ts @@ -0,0 +1,416 @@ +// courtesy of the best 🐇 mary +// https://github.com/mary-ext/boat/blob/trunk/src/views/identity/plc-oplogs.tsx +import { IndexedEntry, Service } from "@atcute/did-plc"; + +export type DiffEntry = + | { + type: "identity_created"; + orig: IndexedEntry; + nullified: boolean; + at: string; + rotationKeys: string[]; + verificationMethods: Record; + alsoKnownAs: string[]; + services: Record; + } + | { + type: "identity_tombstoned"; + orig: IndexedEntry; + nullified: boolean; + at: string; + } + | { + type: "rotation_key_added"; + orig: IndexedEntry; + nullified: boolean; + at: string; + rotation_key: string; + } + | { + type: "rotation_key_removed"; + orig: IndexedEntry; + nullified: boolean; + at: string; + rotation_key: string; + } + | { + type: "verification_method_added"; + orig: IndexedEntry; + nullified: boolean; + at: string; + method_id: string; + method_key: string; + } + | { + type: "verification_method_removed"; + orig: IndexedEntry; + nullified: boolean; + at: string; + method_id: string; + method_key: string; + } + | { + type: "verification_method_changed"; + orig: IndexedEntry; + nullified: boolean; + at: string; + method_id: string; + prev_method_key: string; + next_method_key: string; + } + | { + type: "handle_added"; + orig: IndexedEntry; + nullified: boolean; + at: string; + handle: string; + } + | { + type: "handle_removed"; + orig: IndexedEntry; + nullified: boolean; + at: string; + handle: string; + } + | { + type: "handle_changed"; + orig: IndexedEntry; + nullified: boolean; + at: string; + prev_handle: string; + next_handle: string; + } + | { + type: "service_added"; + orig: IndexedEntry; + nullified: boolean; + at: string; + service_id: string; + service_type: string; + service_endpoint: string; + } + | { + type: "service_removed"; + orig: IndexedEntry; + nullified: boolean; + at: string; + service_id: string; + service_type: string; + service_endpoint: string; + } + | { + type: "service_changed"; + orig: IndexedEntry; + nullified: boolean; + at: string; + service_id: string; + prev_service_type: string; + next_service_type: string; + prev_service_endpoint: string; + next_service_endpoint: string; + }; + +export const createOperationHistory = (entries: IndexedEntry[]): DiffEntry[] => { + const history: DiffEntry[] = []; + + for (let idx = 0, len = entries.length; idx < len; idx++) { + const entry = entries[idx]; + const op = entry.operation; + + if (op.type === "create") { + history.push({ + type: "identity_created", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + rotationKeys: [op.recoveryKey, op.signingKey], + verificationMethods: { atproto: op.signingKey }, + alsoKnownAs: [`at://${op.handle}`], + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: op.service, + }, + }, + }); + } else if (op.type === "plc_operation") { + const prevOp = findLastMatching(entries, (entry) => !entry.nullified, idx - 1)?.operation; + + let oldRotationKeys: string[]; + let oldVerificationMethods: Record; + let oldAlsoKnownAs: string[]; + let oldServices: Record; + + if (!prevOp) { + history.push({ + type: "identity_created", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + rotationKeys: op.rotationKeys, + verificationMethods: op.verificationMethods, + alsoKnownAs: op.alsoKnownAs, + services: op.services, + }); + + continue; + } else if (prevOp.type === "create") { + oldRotationKeys = [prevOp.recoveryKey, prevOp.signingKey]; + oldVerificationMethods = { atproto: prevOp.signingKey }; + oldAlsoKnownAs = [`at://${prevOp.handle}`]; + oldServices = { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: prevOp.service, + }, + }; + } else if (prevOp.type === "plc_operation") { + oldRotationKeys = prevOp.rotationKeys; + oldVerificationMethods = prevOp.verificationMethods; + oldAlsoKnownAs = prevOp.alsoKnownAs; + oldServices = prevOp.services; + } else { + continue; + } + + // Check for rotation key changes + { + const additions = difference(op.rotationKeys, oldRotationKeys); + const removals = difference(oldRotationKeys, op.rotationKeys); + + for (const key of additions) { + history.push({ + type: "rotation_key_added", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + rotation_key: key, + }); + } + + for (const key of removals) { + history.push({ + type: "rotation_key_removed", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + rotation_key: key, + }); + } + } + + // Check for verification method changes + { + for (const id in op.verificationMethods) { + if (!(id in oldVerificationMethods)) { + history.push({ + type: "verification_method_added", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + method_id: id, + method_key: op.verificationMethods[id], + }); + } else if (op.verificationMethods[id] !== oldVerificationMethods[id]) { + history.push({ + type: "verification_method_changed", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + method_id: id, + prev_method_key: oldVerificationMethods[id], + next_method_key: op.verificationMethods[id], + }); + } + } + + for (const id in oldVerificationMethods) { + if (!(id in op.verificationMethods)) { + history.push({ + type: "verification_method_removed", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + method_id: id, + method_key: oldVerificationMethods[id], + }); + } + } + } + + // Check for handle changes + if (op.alsoKnownAs.length === 1 && oldAlsoKnownAs.length === 1) { + if (op.alsoKnownAs[0] !== oldAlsoKnownAs[0]) { + history.push({ + type: "handle_changed", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + prev_handle: oldAlsoKnownAs[0], + next_handle: op.alsoKnownAs[0], + }); + } + } else { + const additions = difference(op.alsoKnownAs, oldAlsoKnownAs); + const removals = difference(oldAlsoKnownAs, op.alsoKnownAs); + + for (const handle of additions) { + history.push({ + type: "handle_added", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + handle: handle, + }); + } + + for (const handle of removals) { + history.push({ + type: "handle_removed", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + handle: handle, + }); + } + } + + // Check for service changes + { + for (const id in op.services) { + if (!(id in oldServices)) { + history.push({ + type: "service_added", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + service_id: id, + service_type: op.services[id].type, + service_endpoint: op.services[id].endpoint, + }); + } else if (!dequal(op.services[id], oldServices[id])) { + history.push({ + type: "service_changed", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + service_id: id, + prev_service_type: oldServices[id].type, + next_service_type: op.services[id].type, + prev_service_endpoint: oldServices[id].endpoint, + next_service_endpoint: op.services[id].endpoint, + }); + } + } + + for (const id in oldServices) { + if (!(id in op.services)) { + history.push({ + type: "service_removed", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + service_id: id, + service_type: oldServices[id].type, + service_endpoint: oldServices[id].endpoint, + }); + } + } + } + } else if (op.type === "plc_tombstone") { + history.push({ + type: "identity_tombstoned", + orig: entry, + nullified: entry.nullified, + at: entry.createdAt, + }); + } + } + + return history; +}; + +function findLastMatching( + arr: T[], + predicate: (item: T) => item is S, + start?: number, +): S | undefined; +function findLastMatching( + arr: T[], + predicate: (item: T) => boolean, + start?: number, +): T | undefined; +function findLastMatching( + arr: T[], + predicate: (item: T) => boolean, + start: number = arr.length - 1, +): T | undefined { + for (let i = start, v: any; i >= 0; i--) { + if (predicate((v = arr[i]))) { + return v; + } + } + + return undefined; +} + +function difference(a: readonly T[], b: readonly T[]): T[] { + const set = new Set(b); + return a.filter((value) => !set.has(value)); +} + +const dequal = (a: any, b: any): boolean => { + let ctor: any; + let len: number; + + if (a === b) { + return true; + } + + if (a && b && (ctor = a.constructor) === b.constructor) { + if (ctor === Array) { + if ((len = a.length) === b.length) { + while (len--) { + if (!dequal(a[len], b[len])) { + return false; + } + } + } + + return len === -1; + } else if (!ctor || ctor === Object) { + len = 0; + + for (ctor in a) { + len++; + + if (!(ctor in b) || !dequal(a[ctor], b[ctor])) { + return false; + } + } + + return Object.keys(b).length === len; + } + } + + return a !== a && b !== b; +}; + +export const groupBy = (items: T[], keyFn: (item: T, index: number) => K): Map => { + const map = new Map(); + + for (let idx = 0, len = items.length; idx < len; idx++) { + const val = items[idx]; + const key = keyFn(val, idx); + + const list = map.get(key); + + if (list !== undefined) { + list.push(val); + } else { + map.set(key, [val]); + } + } + + return map; +}; diff --git a/src/views/repo.tsx b/src/views/repo.tsx index bd08731..7dd4a1b 100644 --- a/src/views/repo.tsx +++ b/src/views/repo.tsx @@ -16,6 +16,9 @@ import { DidDocument } from "@atcute/identity"; import { BlobView } from "./blob.jsx"; import { TextInput } from "../components/text-input.jsx"; import Tooltip from "../components/tooltip.jsx"; +import { CompatibleOperationOrTombstone, defs, IndexedEntry } from "@atcute/did-plc"; +import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js"; +import { localDateFromTimestamp } from "../utils/date.js"; type Tab = "collections" | "backlinks" | "doc" | "blobs"; @@ -28,6 +31,10 @@ const RepoView = () => { const [nsids, setNsids] = createSignal>(); const [tab, setTab] = createSignal("collections"); const [filter, setFilter] = createSignal(); + const [plcOps, setPlcOps] = + createSignal<[IndexedEntry, DiffEntry[]][]>(); + const [showPlcLogs, setShowPlcLogs] = createSignal(false); + const [loading, setLoading] = createSignal(false); let rpc: Client; let pds: string; const did = params.repo; @@ -45,6 +52,81 @@ const RepoView = () => { ); + const DiffItem = (props: { diff: DiffEntry }) => { + const diff = props.diff; + let title = "Unknown log entry"; + let icon = "i-lucide-circle-help"; + let value = ""; + + if (diff.type === "identity_created") { + icon = "i-lucide-bell"; + title = `Identity created`; + } else if (diff.type === "identity_tombstoned") { + icon = "i-lucide-skull"; + title = `Identity tombstoned`; + } else if (diff.type === "handle_added") { + icon = "i-lucide-at-sign"; + title = "Alias added"; + value = diff.handle; + } else if (diff.type === "handle_changed") { + icon = "i-lucide-at-sign"; + title = "Alias updated"; + value = `${diff.prev_handle} → ${diff.next_handle}`; + } else if (diff.type === "handle_removed") { + icon = "i-lucide-at-sign"; + title = `Alias removed`; + value = diff.handle; + } else if (diff.type === "rotation_key_added") { + icon = "i-lucide-key-round"; + title = `Rotation key added`; + value = diff.rotation_key; + } else if (diff.type === "rotation_key_removed") { + icon = "i-lucide-key-round"; + title = `Rotation key removed`; + value = diff.rotation_key; + } else if (diff.type === "service_added") { + icon = "i-lucide-server"; + title = `Service ${diff.service_id} added`; + value = `${diff.service_endpoint}`; + } else if (diff.type === "service_changed") { + icon = "i-lucide-server"; + title = `Service ${diff.service_id} updated`; + value = `${diff.prev_service_endpoint} → ${diff.next_service_endpoint}`; + } else if (diff.type === "service_removed") { + icon = "i-lucide-server"; + title = `Service ${diff.service_id} removed`; + value = `${diff.service_endpoint}`; + } else if (diff.type === "verification_method_added") { + icon = "i-lucide-shield-check"; + title = `Verification method ${diff.method_id} added`; + value = `${diff.method_key}`; + } else if (diff.type === "verification_method_changed") { + icon = "i-lucide-shield-check"; + title = `Verification method ${diff.method_id} updated`; + value = `${diff.prev_method_key} → ${diff.next_method_key}`; + } else if (diff.type === "verification_method_removed") { + icon = "i-lucide-shield-check"; + title = `Verification method ${diff.method_id} removed`; + value = `${diff.method_key}`; + } + + return ( +
+
+

+ {title} +

+
+ {value} +
+ ); + }; + const fetchRepo = async () => { pds = await resolvePDS(did); setDidDoc(didDocCache[did] as DidDocument); @@ -228,87 +310,130 @@ const RepoView = () => { {(didDocument) => ( -
-
+
+
+
+
+ ID + {didDocument().id} +
+ + +
- ID - {didDocument().id} +

Identities

+
    + {(alias) =>
  • {alias}
  • }
    +
+
+
+

Services

+ +
+
+

+ Verification methods +

+
    + + {(verif) => ( +
  • + #{verif.id.split("#")[1]} + {verif.publicKeyMultibase} +
  • + )} +
    +
- - - -
-

Identities

-
    - {(alias) =>
  • {alias}
  • }
    -
-
-

Services

- +
+ +
+ + +
+ +
+
+ +
+ +
+ + +
+
-
-

- Verification methods -

-
    - - {(verif) => ( -
  • - #{verif.id.split("#")[1]} - {verif.publicKeyMultibase} -
  • + +
    + + {([entry, diffs]) => ( +
    + + {localDateFromTimestamp(new Date(entry.createdAt).getTime())} + + {diffs.map((diff) => ( + + ))} +
    )}
    -
-
- - - PLC operation logs