/** * `grantSpaceAccess()` and `revokeSpaceAccess()`, the handler calls that change * a space's access list, and `settleSpaceAccessChanges()`, which the runner * calls to settle what a handler run staged. * `docs/features/space-access-changes.md` describes the whole arrangement. * * A call validates what it can know on its own and stages the change on the * handler's frame. Each space's staged changes then commit as a commit of * their own, the only kind the memory server admits for an access list, ahead * of the handler's own writes: on a client the runner commits them before the * handler's transaction commits, and on a serving runtime they ride that * transaction into the serving loop's wave, which commits them first. The * actor keeps `OWNER` throughout, since no call may change the actor's own * entry, so the ordering never costs the handler its own writes. */ import type { SpaceGrantLevel } from "@commonfabric/api"; import { debugStr } from "@commonfabric/data-model"; import { isWellFormedDID } from "@commonfabric/identity/did"; import { type ACL, aclDocId } from "@commonfabric/memory/acl"; import type { MemorySpace, URI } from "@commonfabric/memory/interface"; import { applyAccessListChanges, validateStoredAcl, writeAcl, } from "../acl-manager.ts"; import { stageSpaceAccessChanges } from "../executor/wave.ts"; import type { Runtime } from "../runtime.ts"; import { RetryImmediately } from "../scheduler/retry-immediately.ts"; import { speculationRunContextOf } from "../speculation/overlay-destination.ts"; import { isStaleReadConflict } from "../storage/rejection.ts"; import { topFrame } from "./frame-context.ts"; import { spaceOfTarget } from "./space-access.ts"; import type { Frame, SpaceAccessChange } from "./types.ts"; /** The levels `grantSpaceAccess()` takes. */ const GRANT_LEVELS: ReadonlySet = new Set([ "READ", "WRITE", "OWNER", ]); /** * Sets `principal`'s entry in the access list of the space `target`'s value * lives in to exactly `level`. See {@link stageChange} for what is checked * when, and `docs/features/space-access-changes.md` for the whole contract. * * @throws Error on every refusal, before anything is staged. */ export function grantSpaceAccess( target: unknown, principal: unknown, level: unknown, ): void { if (!GRANT_LEVELS.has(level)) { throw new Error( "`grantSpaceAccess()` takes a level of `READ`, `WRITE` or `OWNER`; " + debugStr`got $quote${level}.`, ); } stageChange( "grantSpaceAccess()", target, principal, level as SpaceGrantLevel, ); } /** * Removes `principal`'s entry from the access list of the space `target`'s * value lives in. See {@link stageChange} for what is checked when. * * @throws Error on every refusal, before anything is staged. */ export function revokeSpaceAccess(target: unknown, principal: unknown): void { stageChange("revokeSpaceAccess()", target, principal, undefined); } /** * Settles the access-list changes the handler run of `frame` staged. The * runner calls this after the handler body returns and before the handler's * own transaction commits. Where the changes commit turns on the run: * * - On a client they commit on their own, through * {@link commitSpaceAccessChanges}, and the returned promise settles as that * one does. * - On a serving runtime they ride the handler's transaction into the serving * loop's wave, which checks them when the transaction seals and commits * them ahead of everything else the run wrote or sent. * - In a speculative run, a client's echo of a handler a serving runtime runs * for the same event, they commit nowhere: the served run makes them. * * Returns `undefined` when nothing is left to wait for. */ export function settleSpaceAccessChanges( frame: Frame, ): Promise | undefined { const pending = frame.pendingSpaceAccessChanges; const runtime = frame.runtime; const tx = frame.tx; if (pending === undefined || runtime === undefined || tx === undefined) { return undefined; } if (runtime.servingPosture) { frame.pendingSpaceAccessChanges = undefined; stageSpaceAccessChanges(tx, pending); return undefined; } if (speculationRunContextOf(tx) !== undefined) { frame.pendingSpaceAccessChanges = undefined; return undefined; } return commitSpaceAccessChanges(frame); } /** * Commits the access-list changes the handler run of `frame` staged on a * client, one commit per space, each holding the space's changes applied in * call order to the list as this runtime holds it once it has caught up with * the memory server. This is where a change that leaves the list as it was is found out: * that space sends nothing, since writing the value a document already holds * changes nothing. A change that lands at the server after that catch-up is * ordered after this one. * * Each commit reads the list it replaces, so a concurrent change to the list * makes it conflict. The memory server's refusal of a stale read throws * `RetryImmediately`, and the handler runs again against the list as it now * stands. Every other failure fails the handler run, and nothing retries it. * * @throws Error when the actor holds no `OWNER` in a space, when a change * would leave a space with no concrete `OWNER`, or when a commit fails for * any reason but a stale read, which fails the handler run. Changes already * committed for another space stand. */ export async function commitSpaceAccessChanges(frame: Frame): Promise { const pending = frame.pendingSpaceAccessChanges; const runtime = frame.runtime; if (pending === undefined || runtime === undefined) return; frame.pendingSpaceAccessChanges = undefined; for (const [space, changes] of pending) { // Loading the list alone can leave a replica that already holds it behind // the memory server. The round trip after it returns once every update the // server had sent is applied, so the no-op decision below is made against // the server's list as of then. await runtime.getCellFromLink(aclLink(space)).sync(); await runtime.storageManager.open(space).pullToServerHead?.(); const tx = runtime.edit(); tx.tx.immediate = true; try { writeAcl( tx, space, (current) => applyAccessListChanges(space, current, changes), ); } catch (error) { tx.abort(error); throw error; } runtime.prepareTxForCommit(tx); const { error } = await tx.commit().settled; if (error === undefined) continue; if (isStaleReadConflict(error)) { await runtime.awaitCommitRetryReadiness(error); throw new RetryImmediately( `The access list of ${space} changed while a handler changed it`, ); } throw new Error( `The change to the access list of ${space} did not commit: ` + error.message, { cause: error }, ); } } /** * Helper for {@link grantSpaceAccess} and {@link revokeSpaceAccess}, which * checks a change and stages it on the running handler's frame, setting * `principal`'s entry to `level`, or removing it when `level` is `undefined`. * `call` names the call, for errors. * * Checked here, whatever the access list holds: that the call runs in a * handler, for an event that is a trusted gesture, that `target` is a cell in * a space other than the actor's Home space, whose DID is the actor's own, and * that `principal` is a DID other than `"*"`, the space's own and the actor's. * When this runtime already holds the list, the actor's `OWNER` and the * survival of a concrete `OWNER` are checked here as well, so a refusal throws * from the call; the commit checks both again against the list it replaces. A * refusal throws before anything is staged, so a handler that catches it has * staged nothing for that call. Whether a change leaves the list as it is is * not decided here, since the list this runtime holds may be stale; the commit * decides it. * * Refusing the actor's Home space keeps one click from exposing everything a * user keeps there. Every other space the actor holds `OWNER` in stays * reachable, with the trusted gesture as the only bar between a pattern and * its list. * * A service DID or a delegating DID as `principal` is not refused: the memory * server's configuration names those, and nothing hands them to a runtime. */ function stageChange( call: string, target: unknown, principal: unknown, level: SpaceGrantLevel | undefined, ): void { const frame = topFrame(); const runtime = frame?.runtime; const tx = frame?.tx; if ( frame?.frameKind !== "handler" || runtime === undefined || tx === undefined ) { throw new Error( `\`${call}\` is available only in a handler, not in a pattern body, ` + "a `computed()`, or a `lift()`.", ); } if (frame.trustedGesture !== true) { throw new Error( `\`${call}\` requires the handler's event to be a trusted gesture.`, ); } const space = spaceOfTarget(target, call); const actor = runtime.actingPrincipalFor(tx); if (actor === undefined) { throw new Error(`\`${call}\` requires an event with an actor.`); } if (space === actor) { throw new Error( `\`${call}\` cannot change the access list of the Home space of the ` + "principal it acts for.", ); } if (!isWellFormedDID(principal)) { throw new Error( `\`${call}\` takes a principal's DID, never \`*\`; ` + debugStr`got $quote${principal}.`, ); } if (principal === space) { throw new Error( `\`${call}\` cannot change the entry of the space's own DID.`, ); } if (principal === actor) { throw new Error( `\`${call}\` cannot change the entry of the principal it acts for.`, ); } const change: SpaceAccessChange = { principal, level, actor }; const staged = frame.pendingSpaceAccessChanges?.get(space) ?? []; const current = knownAcl(runtime, space); if (current !== undefined) { applyAccessListChanges(space, current, [...staged, change]); } (frame.pendingSpaceAccessChanges ??= new Map()).set(space, [ ...staged, change, ]); } /** * Helper for {@link stageChange}, which returns the access list of `space` as * this runtime holds it, or `undefined` when it holds none. The read is * outside the handler's transaction, so that committing the change does not * make the handler's own commit conflict with it. */ function knownAcl(runtime: Runtime, space: MemorySpace): ACL | undefined { const tx = runtime.edit(); try { const envelope = tx.readOrThrow({ ...aclLink(space), type: "application/json", }) as { readonly value?: unknown } | undefined; return validateStoredAcl(envelope?.value) ?? undefined; } finally { tx.abort(); } } /** Returns the link to the access-list document of `space`. */ function aclLink( space: MemorySpace, ): { space: MemorySpace; id: URI; path: [] } { return { space, id: aclDocId(space) as URI, path: [] }; }