Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
11 kB · 260 lines
TypeScript
at commit 18ba4fe0
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261// The write batch: every create, update and delete the operator has lined up// across both collections, written together by one// com.atproto.repo.applyWrites. Pure data, so tests/check-flows.mjs can hold// each flow's batch to the call it becomes.//// It keeps records only, in sessionStorage, keyed to the repository it is// for. An op's "before" is never kept: it is whatever the repository holds// when the batch is shown, and the write is guarded by the commit those// records were read at.
import type { Entry } from "./checks.ts";import { BINDING, POLICY, type Collection, type StoredRecord } from "./collections.ts";
type Json = Record<string, unknown>;
export type Op = | { op: "create" | "update"; collection: Collection; rkey: string; record: Json } | { op: "delete"; collection: Collection; rkey: string };
export interface Batch { /** The DID of the repository the batch writes to. */ repo: string; ops: Op[];}
/** The records a batch is laid over. */export type Current = Record<Collection, StoredRecord[]>;
const STORAGE_KEY = "policy-site:batch";const COLLECTIONS: readonly string[] = [POLICY, BINDING];
const same = (a: Op, collection: Collection, rkey: string) => a.collection === collection && a.rkey === rkey;const find = (current: Current, collection: Collection, rkey: string) => current[collection].find((stored) => stored.rkey === rkey);
/** The batch saved for `repo`, or an empty one. */export function loadBatch(storage: Pick<Storage, "getItem">, repo: string): Batch { try { const saved = JSON.parse(storage.getItem(STORAGE_KEY) ?? "null") as unknown; if (isBatch(saved) && saved.repo === repo) return saved; } catch { // An unreadable batch is no batch. } return { repo, ops: [] };}
export function saveBatch(storage: Pick<Storage, "setItem" | "removeItem">, batch: Batch): void { if (batch.ops.length) storage.setItem(STORAGE_KEY, JSON.stringify(batch)); else storage.removeItem(STORAGE_KEY);}
/** * `batch` with the record at `collection`/`rkey` set to `record`: an update * if the repository holds it now, a create if not. Setting a record back to * what the repository holds drops its op. */export function setRecord(batch: Batch, current: Current, collection: Collection, rkey: string, record: Json): Batch { const ops = batch.ops.filter((op) => !same(op, collection, rkey)); const stored = find(current, collection, rkey); if (stored && canonical(stored.value) === canonical(record)) return { ...batch, ops }; return { ...batch, ops: [...ops, { op: stored ? "update" : "create", collection, rkey, record }] };}
/** `batch` with the record deleted, or with its pending create dropped. */export function deleteRecord(batch: Batch, current: Current, collection: Collection, rkey: string): Batch { const ops = batch.ops.filter((op) => !same(op, collection, rkey)); return find(current, collection, rkey) ? { ...batch, ops: [...ops, { op: "delete", collection, rkey }] } : { ...batch, ops };}
/** `batch` without its op on `collection`/`rkey`. */export function withoutOp(batch: Batch, collection: Collection, rkey: string): Batch { return { ...batch, ops: batch.ops.filter((op) => !same(op, collection, rkey)) };}
/** The record at `collection`/`rkey` as it will read: the batch's, or the repository's. */export function pending(batch: Batch, current: Current, collection: Collection, rkey: string): Json | null { const op = batch.ops.find((o) => same(o, collection, rkey)); if (op) return op.op === "delete" ? null : op.record; return find(current, collection, rkey)?.value ?? null;}
/** Both collections as they will read once the batch is written. */export function prospective(current: Current, batch: Batch): Record<Collection, Entry[]> { const next = (collection: Collection) => { const out = new Map<string, unknown>(current[collection].map((stored) => [stored.rkey, stored.value])); for (const op of batch.ops) { if (op.collection !== collection) continue; if (op.op === "delete") out.delete(op.rkey); else out.set(op.rkey, op.record); } return [...out].map(([rkey, value]) => ({ rkey, value })); }; return { [POLICY]: next(POLICY), [BINDING]: next(BINDING) };}
/** Each op that no longer fits the repository, with why. */export function conflicts(current: Current, batch: Batch): { op: Op; message: string }[] { return batch.ops.flatMap((op) => { const exists = find(current, op.collection, op.rkey) !== undefined; if (op.op === "create" && exists) return [{ op, message: `${op.collection}/${op.rkey} already exists` }]; if (op.op !== "create" && !exists) return [{ op, message: `${op.collection}/${op.rkey} no longer exists` }]; return []; });}
/** Each op with the record before and after it. */export function diffs(current: Current, batch: Batch): { op: Op; before: Json | null; after: Json | null }[] { return ordered(batch.ops).map((op) => ({ op, before: find(current, op.collection, op.rkey)?.value ?? null, after: op.op === "delete" ? null : op.record, }));}
/** The one com.atproto.repo.applyWrites input that writes the batch. */export function applyWritesInput(batch: Batch, swapCommit: string): Json { return { repo: batch.repo, swapCommit, writes: ordered(batch.ops).map((op) => op.op === "delete" ? { $type: "com.atproto.repo.applyWrites#delete", collection: op.collection, rkey: op.rkey } : { $type: `com.atproto.repo.applyWrites#${op.op}`, collection: op.collection, rkey: op.rkey, value: op.record }, ), };}
/** * Ops in the order they read best: policies written, bindings written, * bindings deleted, policies deleted. The write is one commit either way. */function ordered(ops: Op[]): Op[] { const key = (op: Op) => (op.op === "delete" ? (op.collection === BINDING ? 2 : 3) : op.collection === POLICY ? 0 : 1); return [...ops].sort((a, b) => key(a) - key(b));}
/** A policy's address, as bindings name it. */export const policyUri = (repo: string, rkey: string) => `at://${repo}/${POLICY}/${rkey}`;
/** A binding that applies a policy, and whom it reaches. */export interface Applied { binding: string; /** The subjects it names that its excludes leave. */ subjects: number; /** `subjects`, `descendants`, or both. */ includes: string[];}
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : []);
/** * The bindings in `bindings` that apply the policy at `rkey` to somebody. * * Naming a policy is not enough: a binding reaches nobody when it names no * subject, includes neither the subjects nor their descendants, or excludes * every subject it names and does not include their descendants. This is the * one place that decides it, for the cards, the confirm step and the unbound * page alike. */export function appliedBy(repo: string, rkey: string, bindings: Entry[]): Applied[] { const uri = policyUri(repo, rkey); return bindings.flatMap(({ rkey: binding, value }) => { const record = value as Record<string, unknown>; if (!strings(record.policies).includes(uri)) return []; const includes = strings(record.includes); const excludes = new Set(strings(record.excludes)); const subjects = strings(record.subjects).filter((did) => !excludes.has(did)); const named = strings(record.subjects).length > 0; const reaches = named && (includes.includes("descendants") || (includes.includes("subjects") && subjects.length > 0)); return reaches ? [{ binding, subjects: subjects.length, includes }] : []; });}
/** A policy's applied status, as the page words it. */export function appliedLabel(applied: Applied[]): string { if (applied.length === 0) return "unbound — applies to nobody"; return applied .map(({ binding, subjects, includes }) => `applied via ${binding} to ${subjects} ${subjects === 1 ? "subject" : "subjects"}${ includes.includes("descendants") ? " and their descendants" : "" }`, ) .join("; ");}
/** * What the bind step offers for a policy: bind it with a new binding, add it * to one of `others`, or keep the bindings that already apply it. Every one * of them leaves the policy applied to somebody; saving it unbound is its own * choice, off this path. */export function bindChoices(applied: Applied[], others: Entry[]): { value: string; label: string }[] { return [ { value: "new", label: "Bind it with a new binding" }, ...others.map(({ rkey }) => ({ value: `add:${rkey}`, label: `Add it to binding ${rkey}` })), ...(applied.length ? [{ value: "keep", label: `Keep its bindings: ${appliedLabel(applied)}` }] : []), ];}
/** Every policy in `policies` that no binding in `bindings` reaches. */export function unbound(repo: string, policies: Entry[], bindings: Entry[]): string[] { return policies.map(({ rkey }) => rkey).filter((rkey) => appliedBy(repo, rkey, bindings).length === 0);}
/** * The policies the batch leaves applying to nobody: the ones it writes, and * the ones a binding it writes stops reaching. */export function newlyUnbound(repo: string, current: Current, batch: Batch): string[] { const next = prospective(current, batch); const before = current[BINDING].map(({ rkey, value }) => ({ rkey, value })); const touched = new Set(batch.ops.filter((op) => op.collection === POLICY).map((op) => op.rkey)); return unbound(repo, next[POLICY], next[BINDING]).filter( (rkey) => touched.has(rkey) || appliedBy(repo, rkey, before).length > 0, );}
/** * Whether `record` is one of `catalog`'s policies, as named, and still reads * as it does there. Compared on content alone: key order and `createdAt` * aside. */export function upstream( record: Json, catalog: { name: string; record: Json }[],): { name: string; matches: boolean } | null { const entry = catalog.find(({ name }) => name === record.name); if (!entry) return null; const { createdAt: _a, ...mine } = record; const { createdAt: _b, ...theirs } = entry.record; return { name: entry.name, matches: canonical(mine) === canonical(theirs) };}
/** JSON with every object's keys sorted, to compare records by content. */function canonical(value: unknown): string { return JSON.stringify(value, (_key, item: unknown) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : item, );}
function isBatch(value: unknown): value is Batch { if (typeof value !== "object" || value === null) return false; const { repo, ops } = value as { repo?: unknown; ops?: unknown }; return ( typeof repo === "string" && Array.isArray(ops) && ops.every((op: unknown) => { const { op: kind, collection, rkey, record } = (op ?? {}) as Record<string, unknown>; return ( typeof collection === "string" && COLLECTIONS.includes(collection) && typeof rkey === "string" && (kind === "delete" || ((kind === "create" || kind === "update") && typeof record === "object" && record !== null)) ); }) );}