// 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; 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; 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, 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, 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 { const next = (collection: Collection) => { const out = new Map(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; 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; return ( typeof collection === "string" && COLLECTIONS.includes(collection) && typeof rkey === "string" && (kind === "delete" || ((kind === "create" || kind === "update") && typeof record === "object" && record !== null)) ); }) ); }