atproto explorer pdsls.dev
atproto tool

PLC operation logs (#43)

Changed files
+616 -75
src
utils
views
+416
src/utils/plc-logs.ts
···
+
// 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<string, string>;
+
alsoKnownAs: string[];
+
services: Record<string, { type: string; endpoint: string }>;
+
}
+
| {
+
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<string, string>;
+
let oldAlsoKnownAs: string[];
+
let oldServices: Record<string, Service>;
+
+
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<T, S extends T>(
+
arr: T[],
+
predicate: (item: T) => item is S,
+
start?: number,
+
): S | undefined;
+
function findLastMatching<T>(
+
arr: T[],
+
predicate: (item: T) => boolean,
+
start?: number,
+
): T | undefined;
+
function findLastMatching<T>(
+
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<T>(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 = <K, T>(items: T[], keyFn: (item: T, index: number) => K): Map<K, T[]> => {
+
const map = new Map<K, T[]>();
+
+
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;
+
};
+200 -75
src/views/repo.tsx
···
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";
···
const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>();
const [tab, setTab] = createSignal<Tab>("collections");
const [filter, setFilter] = createSignal<string>();
+
const [plcOps, setPlcOps] =
+
createSignal<[IndexedEntry<CompatibleOperationOrTombstone>, DiffEntry[]][]>();
+
const [showPlcLogs, setShowPlcLogs] = createSignal(false);
+
const [loading, setLoading] = createSignal(false);
let rpc: Client;
let pds: string;
const did = params.repo;
···
{props.label}
</button>
);
+
+
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 (
+
<div class="grid grid-cols-[min-content_1fr] items-center">
+
<div class={icon + ` mr-1 shrink-0 text-lg`} />
+
<p
+
classList={{
+
"font-semibold": true,
+
"text-gray-500 line-through dark:text-gray-400": diff.orig.nullified,
+
}}
+
>
+
{title}
+
</p>
+
<div></div>
+
{value}
+
</div>
+
);
+
};
const fetchRepo = async () => {
pds = await resolvePDS(did);
···
<Show when={tab() === "doc"}>
<Show when={didDoc()}>
{(didDocument) => (
-
<div class="break-anywhere flex flex-col gap-y-1">
-
<div class="flex items-center justify-between gap-2">
+
<div class="break-anywhere flex flex-col gap-y-2">
+
<div class="flex flex-col gap-y-1">
+
<div class="flex items-center justify-between gap-2">
+
<div>
+
<span class="font-semibold text-stone-600 dark:text-stone-400">ID </span>
+
<span>{didDocument().id}</span>
+
</div>
+
<Tooltip text="DID Document">
+
<a
+
href={
+
did.startsWith("did:plc") ?
+
`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}`
+
: `https://${did.split("did:web:")[1]}/.well-known/did.json`
+
}
+
target="_blank"
+
>
+
<div class="i-lucide-external-link text-lg" />
+
</a>
+
</Tooltip>
+
</div>
+
<div>
+
<p class="font-semibold text-stone-600 dark:text-stone-400">Identities</p>
+
<ul class="ml-2">
+
<For each={didDocument().alsoKnownAs}>{(alias) => <li>{alias}</li>}</For>
+
</ul>
+
</div>
<div>
-
<span class="font-semibold text-stone-600 dark:text-stone-400">ID </span>
-
<span>{didDocument().id}</span>
+
<p class="font-semibold text-stone-600 dark:text-stone-400">Services</p>
+
<ul class="ml-2">
+
<For each={didDocument().service}>
+
{(service) => (
+
<li class="flex flex-col">
+
<span>#{service.id.split("#")[1]}</span>
+
<a
+
class="w-fit text-blue-400 hover:underline"
+
href={service.serviceEndpoint.toString()}
+
target="_blank"
+
>
+
{service.serviceEndpoint.toString()}
+
</a>
+
</li>
+
)}
+
</For>
+
</ul>
+
</div>
+
<div>
+
<p class="font-semibold text-stone-600 dark:text-stone-400">
+
Verification methods
+
</p>
+
<ul class="ml-2">
+
<For each={didDocument().verificationMethod}>
+
{(verif) => (
+
<li class="flex flex-col">
+
<span>#{verif.id.split("#")[1]}</span>
+
<span>{verif.publicKeyMultibase}</span>
+
</li>
+
)}
+
</For>
+
</ul>
</div>
-
<Tooltip text="DID Document">
-
<a
-
href={
-
did.startsWith("did:plc") ?
-
`${localStorage.plcDirectory ?? "https://plc.directory"}/${did}`
-
: `https://${did.split("did:web:")[1]}/.well-known/did.json`
-
}
-
target="_blank"
+
</div>
+
<div class="flex justify-between">
+
<Show when={did.startsWith("did:plc")}>
+
<div class="flex items-center gap-1">
+
<button
+
type="button"
+
onclick={async () => {
+
if (!plcOps()) {
+
setLoading(true);
+
const response = await fetch(`https://plc.directory/${did}/log/audit`);
+
const json = await response.json();
+
const logs = defs.indexedEntryLog.parse(json);
+
const opHistory = createOperationHistory(logs).reverse();
+
setPlcOps(Array.from(groupBy(opHistory, (item) => item.orig)));
+
setLoading(false);
+
}
+
+
setShowPlcLogs(!showPlcLogs());
+
}}
+
class="dark:hover:bg-dark-100 dark:bg-dark-300 focus:outline-1.5 dark:shadow-dark-900 flex items-center gap-1 rounded-lg bg-white px-2 py-1.5 text-xs font-bold shadow-sm hover:bg-zinc-200/50 focus:outline-slate-900 dark:focus:outline-slate-100"
+
>
+
<div class="i-lucide-logs text-sm" />
+
{showPlcLogs() ? "Hide" : "Show"} PLC logs
+
</button>
+
<Show when={loading()}>
+
<div class="i-lucide-loader-circle animate-spin text-xl" />
+
</Show>
+
</div>
+
</Show>
+
<Show when={error()?.length === 0 || error() === undefined}>
+
<div
+
classList={{
+
"flex items-center gap-1": true,
+
"flex-row-reverse": did.startsWith("did:web"),
+
}}
>
-
<div class="i-lucide-external-link text-lg" />
-
</a>
-
</Tooltip>
+
<Show when={downloading()}>
+
<div class="i-lucide-loader-circle animate-spin text-xl" />
+
</Show>
+
<button
+
type="button"
+
onclick={() => downloadRepo()}
+
class="dark:hover:bg-dark-100 dark:bg-dark-300 focus:outline-1.5 dark:shadow-dark-900 flex items-center gap-1 rounded-lg bg-white px-2 py-1.5 text-xs font-bold shadow-sm hover:bg-zinc-200/50 focus:outline-slate-900 dark:focus:outline-slate-100"
+
>
+
<div class="i-lucide-download text-sm" />
+
Export Repo
+
</button>
+
</div>
+
</Show>
</div>
-
<div>
-
<p class="font-semibold text-stone-600 dark:text-stone-400">Identities</p>
-
<ul class="ml-2">
-
<For each={didDocument().alsoKnownAs}>{(alias) => <li>{alias}</li>}</For>
-
</ul>
-
</div>
-
<div>
-
<p class="font-semibold text-stone-600 dark:text-stone-400">Services</p>
-
<ul class="ml-2">
-
<For each={didDocument().service}>
-
{(service) => (
-
<li class="flex flex-col">
-
<span>#{service.id.split("#")[1]}</span>
-
<a
-
class="w-fit text-blue-400 hover:underline"
-
href={service.serviceEndpoint.toString()}
-
target="_blank"
-
>
-
{service.serviceEndpoint.toString()}
-
</a>
-
</li>
-
)}
-
</For>
-
</ul>
-
</div>
-
<div>
-
<p class="font-semibold text-stone-600 dark:text-stone-400">
-
Verification methods
-
</p>
-
<ul class="ml-2">
-
<For each={didDocument().verificationMethod}>
-
{(verif) => (
-
<li class="flex flex-col">
-
<span>#{verif.id.split("#")[1]}</span>
-
<span>{verif.publicKeyMultibase}</span>
-
</li>
+
<Show when={showPlcLogs()}>
+
<div class="flex flex-col gap-1 text-sm">
+
<For each={plcOps()}>
+
{([entry, diffs]) => (
+
<div class="flex flex-col">
+
<span class="text-neutral-500 dark:text-neutral-400">
+
{localDateFromTimestamp(new Date(entry.createdAt).getTime())}
+
</span>
+
{diffs.map((diff) => (
+
<DiffItem diff={diff} />
+
))}
+
</div>
)}
</For>
-
</ul>
-
</div>
-
<Show when={did.startsWith("did:plc")}>
-
<a
-
class="flex w-fit items-center text-blue-400 hover:underline"
-
href={`https://boat.kelinci.net/plc-oplogs?q=${did}`}
-
target="_blank"
-
>
-
PLC operation logs <div class="i-lucide-external-link ml-0.5 text-sm" />
-
</a>
-
</Show>
-
<Show when={error()?.length === 0 || error() === undefined}>
-
<div class="flex items-center gap-1">
-
<button
-
type="button"
-
onclick={() => downloadRepo()}
-
class="dark:hover:bg-dark-100 dark:bg-dark-300 focus:outline-1.5 dark:shadow-dark-900 flex items-center gap-1 rounded-lg bg-white px-2 py-1.5 text-xs font-bold shadow-sm hover:bg-zinc-200/50 focus:outline-slate-900 dark:focus:outline-slate-100"
-
>
-
<div class="i-lucide-download text-sm" />
-
Export Repo
-
</button>
-
<Show when={downloading()}>
-
<div class="i-lucide-loader-circle animate-spin text-xl" />
-
</Show>
</div>
</Show>
</div>