// 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 -- except `appliedBy`, `unbound` // and `newlyUnbound`, which ask a `Checks` whom a binding reaches, the same // question `checks.appliedBy` answers through `Coverage::covers`. // // 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 { Applied, Checks, Entry } from "./checks.ts"; import { BINDING, OPERATOR, POLICY, type Collection, type StoredRecord } from "./collections.ts"; export type { Applied } from "./checks.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), [OPERATOR]: next(OPERATOR) }; } /** 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 []; }); } /** * Why this batch cannot be written yet, in one plain sentence, or null when * it may be. One place decides it: the button that would write it and the * write itself ask the same question. * * `report` is what the checks make of the records with the batch written, and * null while the checks are still loading — nothing is written unchecked. */ export function blockedReason( current: Current, batch: Batch, head: string | null, report: { problems: { severity: string }[] } | null, ): string | null { if (batch.ops.length === 0) return "The batch is empty."; if (report === null) return "The policy checks are still loading. Nothing is written until they are here."; if (conflicts(current, batch).length > 0) { return "The records changed after they were read. Review the changes first."; } if (report.problems.some((problem) => problem.severity === "error")) { return "The page's check finds errors. Fix them first."; } if (head === null) return "The commit these records were read at is not known."; return null; } /** 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}`; /** * The bindings in `bindings` that apply the policy at `rkey` to somebody, on * `server`. * * 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 -- and a * binding whose only named subject is the account it is bound to, with only * `descendants` included, reaches that account never: `Coverage::covers` * skips the subject itself on the descendants path, the same as a real * write is judged. `checks.appliedBy` is the one place that decides it, so * the cards, the confirm step and the unbound page cannot disagree with a * server about whom a binding reaches. `null` while the checks are still * loading answers nothing applies yet, rather than guessing. */ export function appliedBy(checks: Checks | null, server: string, repo: string, rkey: string, bindings: Entry[]): Applied[] { if (!checks) return []; return checks.appliedBy(bindings, policyUri(repo, rkey), server); } /** 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. `null` * checks answers nothing is unbound yet, the same as `appliedBy`. */ export function unbound(checks: Checks | null, server: string, repo: string, policies: Entry[], bindings: Entry[]): string[] { if (!checks) return []; return policies.map(({ rkey }) => rkey).filter((rkey) => appliedBy(checks, server, 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(checks: Checks | null, server: string, 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(checks, server, repo, next[POLICY], next[BINDING]).filter( (rkey) => touched.has(rkey) || appliedBy(checks, server, 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)) ); }) ); }