/** Native host confirmation for sealing a value into a custody room. */ import { type CellHandle, type RuntimeClient, } from "@commonfabric/runtime-client"; import { consume } from "@lit/context"; import { css, html, nothing, type PropertyValues } from "lit"; import { property } from "lit/decorators.js"; import { BaseElement } from "../../core/base-element.ts"; import { runtimeContext } from "../../runtime-context.ts"; type SealPreview = Awaited>; type SealBinding = { runtime: RuntimeClient; draft: CellHandle; terms: CellHandle; policy: CellHandle; sources: CellHandle; box: CellHandle | undefined; generation: number; }; /** What the room's terms state for the confirmation to show. */ type SealSummary = { question: string | undefined; answers: string[] | undefined; seats: string[]; leakBits: string | undefined; }; /** * What the confirmation says in place of a bound on what an answer reveals * when the room's policy does not require the seal's input witness on its * release. Then a member's own code can run the room's releasing code over * the actor's entry and values it made up, as many times as it likes, so no * per-answer bound holds. * * TODO(custody-refuse-unwitnessed): once the other conditions a witnessed * release rests on can be checked (`releaseRequiresSealWitness`), the seal * should refuse such a policy and this warning goes with it. */ const UNWITNESSED_RELEASE_WARNING = "This room protects your answer's inputs from members' honest code only; a member running their own code can learn your stance one answer at a time."; /** The longest room-authored string the confirmation shows, in characters. */ const MAX_TERMS_TEXT = 280; /** * Room-authored text made safe to place beside host-verified fields: control * characters, format characters (the bidirectional marks and overrides, the * zero-width characters, and the byte order mark among them), and line and * paragraph separators removed, so it cannot reorder the dialog's own text or * hide characters in its own, and capped in length. */ function roomText(value: string): string { const plain = value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ""); return plain.length > MAX_TERMS_TEXT ? `${plain.slice(0, MAX_TERMS_TEXT)}…` : plain; } /** * Reads the display fields of the terms the worker sealed. The terms are the * exact document the value is sealed under, so the confirmation shows them * rather than anything the pattern renders around it. The room's author wrote * `question` and `answers`, and nothing checks that the room's policy * releases only the listed answers, so the confirmation presents them as what * the terms say. For `k` distinct answers, an answer drawn from them carries * at most `log₂ k` bits. */ export function summarizeCustodyTerms(terms: unknown): SealSummary { const record = terms !== null && typeof terms === "object" && !Array.isArray(terms) ? terms as Record : {}; const question = typeof record.question === "string" ? roomText(record.question) : undefined; const seats = Array.isArray(record.seats) ? record.seats.filter((seat): seat is string => typeof seat === "string") : []; if (!Array.isArray(record.answers) || record.answers.length === 0) { return { question, answers: undefined, seats, leakBits: undefined }; } // Distinct by their JSON, so the string "1" and the number 1 stay two. const distinct = new Map(); for (const answer of record.answers) { distinct.set(JSON.stringify(answer), answer); } const answers = [...distinct.values()].map((answer) => roomText(typeof answer === "string" ? answer : JSON.stringify(answer)) ); const bits = Math.log2(answers.length); return { question, answers, seats, leakBits: Number.isInteger(bits) ? `${bits}` : `~${bits.toFixed(1)}`, }; } /** * A principal the runtime checked, in an element of its own: bidirectionally * isolated, so nothing inside it reorders the dialog's text around it, and * apart from the dialog's annotations, so a principal that ends in `(you)` * still reads as one string. `*` is shown as `Anyone`. */ function principal(value: string) { return value === "*" ? html`Anyone` : html`${value}`; } /** A host annotation beside a principal, never inside its text. */ function annotation(text: string) { return html` ${text} `; } /** * The policy as a person can compare it. The runtime admits the policy only * when the actor's trust configuration names its manifest digest, so the * digest is the checked fact. A trust statement need not name the symbol or * the module, which the room's reference supplies, so those are shown as what * the reference names, as room text. */ function describePolicy(policy: unknown) { const atom = policy as Record | undefined; if (typeof atom?.policyDigest !== "string") return nothing; const named = (value: unknown) => roomText(typeof value === "string" ? value : ""); const symbol = named(atom.symbol); const moduleIdentity = named(atom.moduleIdentity); return html` ${roomText(atom.policyDigest)}

A manifest you trust to decide what the room releases. The room's reference names it ${symbol} in ${moduleIdentity}.

`; } /** * A source atom as a person reads it: its name or class, and its kind. The * name and class are the actor's own, but they are text, so they are shown * with the same stripping as room text. */ function describeSource(source: unknown): string { const atom = source as Record; if (typeof atom?.name === "string") return `${roomText(atom.name)} (context)`; if (typeof atom?.class === "string") { return `${roomText(atom.class)} (resource)`; } return roomText(JSON.stringify(source)); } /** * Seals the actor's draft into a custody room from a native host dialog. The * dialog first shows what the worker read and checked: the room space, who * can read it now, the seats, the policy that governs release, and which of * the actor's sources go in. Below that, set apart, it shows what the room's * terms say: the question, the answers they list, and the bound on what one of * those answers reveals. When the worker found that the room's policy does * not require the seal's input witness on what it releases, no such bound * holds, and a warning saying so replaces it. The exact values are under * details. Only a trusted * click on the dialog's own confirmation seals. * * The seal writes a link to the instance's box into `$box`, when bound, in * the transaction that writes the entry: the box is the one document the * room's projector reads, and a pattern has no other way to address it. The * link is the seal's own write, so a release rule that requires the seal's * witness also covers which document the projector reads as its box. Reading * it stays label-gated, so what the pattern computes from it leaves the room * only through the room policy's own release rules. The blinded key of the * actor's entry never reaches the pattern. * * @element cf-custody-seal * @fires cf-sealed - The value is sealed; `detail.instance` is the instance * sealed into, the digest of the terms with each seat resolved to its DID. * It names no member, but code holding it can test a guess at the whole * set of seat DIDs against it */ export class CFCustodySeal extends BaseElement { static override styles = [ BaseElement.baseStyles, css` :host { display: block; } *, *::before, *::after { box-sizing: border-box; } button { font: inherit; padding: .7rem 1rem; border: 1px solid #53655c; border-radius: .5rem; background: #fff; color: #203b2e; cursor: pointer; } button:disabled { opacity: .5; cursor: default; } dialog { all: initial; box-sizing: border-box; position: fixed; inset: 0; margin: auto; width: min(42rem, calc(100vw - 2rem)); max-height: calc(100vh - 2rem); overflow: auto; padding: 1.5rem; border: 2px solid #244938; border-radius: .75rem; background: #fff; color: #18221c; font: 16px/1.5 system-ui, sans-serif; direction: ltr; unicode-bidi: isolate; } h3 { font-size: 1.05rem; margin: 1.25rem 0 0; } .note { margin: .25rem 0 0; font-size: .9rem; } dialog:not([open]) { display: none; } dialog::backdrop { background: #0009; } h2 { font-size: 1.35rem; margin: 0 0 1rem; } dt { font-weight: 600; margin-top: .75rem; } dd { margin: 0; overflow-wrap: anywhere; } ul { margin: 0; padding-left: 1.25rem; } pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #f3f5f3; padding: 1rem; border: 1px solid #ccd5cf; font: 14px/1.5 monospace; } summary { cursor: pointer; margin: 1rem 0 .5rem; } .actions { display: flex; gap: .75rem; justify-content: flex-end; } .confirm { background: #244938; color: #fff; } [role="alert"] { color: #932c22; } .warning { margin: 1rem 0 0; padding: .75rem 1rem; border: 2px solid #932c22; border-radius: .5rem; background: #fbeeec; color: #5c1711; font-weight: 600; } .principal, .digest { unicode-bidi: isolate; font: 14px/1.5 monospace; } .annotation { display: inline-block; padding: 0 .4rem; border: 1px solid #53655c; border-radius: .75rem; font-size: .8rem; color: #203b2e; background: #f3f5f3; } `, ]; /** Runtime supplied by the host's context provider. */ @consume({ context: runtimeContext, subscribe: true }) @property({ attribute: false }) accessor runtime: RuntimeClient | undefined; /** The actor's draft, whose exact value is sealed. */ @property({ attribute: false }) accessor draft: CellHandle | undefined; /** The room's terms document; its space is the room the value enters. */ @property({ attribute: false }) accessor terms: CellHandle | undefined; /** A cell holding the room's custody policy reference. */ @property({ attribute: false }) accessor policy: CellHandle | undefined; /** The actor's source policy, in the actor's home space. */ @property({ attribute: false }) accessor sources: CellHandle | undefined; /** * Optional cell in the room space that receives a link to the instance's * box, written by the seal. */ @property({ attribute: false }) accessor box: CellHandle | undefined; #preview: SealPreview | undefined; #binding: SealBinding | undefined; #busy = false; #error = ""; #generation = 0; /** Exercises host workflow without manufacturing a trusted DOM event. */ get accessForTestingOnly(): { prepare(): Promise; confirm(event: Event): Promise; readonly preview: SealPreview | undefined; readonly error: string; } { // deno-lint-ignore no-this-alias const component = this; return { prepare: () => this.#prepare(), confirm: (event) => this.#confirm(event), get preview() { return component.#preview; }, get error() { return component.#error; }, }; } override willUpdate(changed: PropertyValues): void { super.willUpdate(changed); if ( ["draft", "terms", "policy", "sources", "box", "runtime"].some((key) => changed.has(key) ) ) { this.#invalidate(); } } override disconnectedCallback(): void { this.#invalidate(); super.disconnectedCallback(); } override render() { const preview = this.#preview; const summary = preview ? summarizeCustodyTerms(preview.terms) : undefined; const heldWith = preview?.heldWith ?? []; const readsRoom = (person: string) => (preview?.readers ?? []).some((reader) => reader.principal === person || reader.principal === "*" ); // The bound on what an answer reveals is shown only when the worker found // the room's release witnessed. A preview that does not say so, including // one from a worker that predates the field, gets the warning instead. const bounded = preview?.witnessedRelease === true; return html` ${this.#error ? html`

${this.#error}

` : nothing}

Join this room with these terms?

Room
${preview ? principal(preview.room) : nothing}
Who can read the room now, and so see the answer
    ${(preview?.readers ?? []).map((reader) => html`
  • ${principal(reader.principal)}${ reader.principal === preview?.actor ? annotation("you") : nothing }${ reader.principal !== "*" && !summary?.seats.includes(reader.principal) ? annotation("no seat") : nothing }
  • ` )}

The room's owners can add readers later, and the service that hosts the room can read it.

Seats
    ${(summary?.seats ?? []).map((seat) => html`
  • ${principal(seat)}${ seat === preview?.actor ? annotation("you") : nothing }
  • ` )}
Policy that decides what comes out
${describePolicy(preview?.policy)}
What goes in from your sources
${preview && preview.sources.length > 0 ? html`
    ${ preview.sources.map((source) => html`
  • ${describeSource(source)}
  • ` ) }
` : heldWith.length > 0 ? "None, beyond data you share with the people below." : "Nothing beyond what you entered yourself."}
${heldWith.length > 0 ? html`
Drawn from data you share with
    ${heldWith.map((group) => html`
  • ${ group.map((person, index) => html`${index > 0 ? ", " : nothing}${principal(person)}${ readsRoom(person) ? annotation("reads the room") : nothing }` ) }
  • ` )}

For example, a conversation.

` : nothing}

What the room's terms say

${summary?.question ? html`
The room asks
${summary.question}
` : nothing}
Answers the terms list
${summary?.answers ? html`
    ${ summary.answers.map((answer) => html`
  • ${answer}
  • `) }
` : "These terms do not list the answers the room can give."}
${preview && !bounded ? html`

${UNWITNESSED_RELEASE_WARNING}

` : html`

${summary?.leakBits !== undefined ? `If the room releases only these answers, each answer reveals at most ${summary.leakBits} ${ summary.leakBits === "1" ? "bit" : "bits" } about your values.` : "These terms state no bound on what an answer reveals."}

`}
Details

Your sealed values:

${preview
            ? JSON.stringify(preview.stance, null, 2)
            : ""}

The terms, as sealed:

${preview
            ? JSON.stringify(preview.terms, null, 2)
            : ""}
`; } #bound(): boolean { return !!(this.draft && this.terms && this.policy && this.sources); } /** Drops the review whenever its binding is no longer current. */ #invalidate(): void { if (this.#preview && this.#binding) { this.#releasePreview(this.#binding.runtime, this.#preview.id); } this.#generation++; this.#preview = undefined; this.#binding = undefined; this.#busy = false; this.shadowRoot?.querySelector("dialog")?.close(); } /** Teardown can outlive its runtime connection, so cancellation is best effort. */ #releasePreview(runtime: RuntimeClient, id: string): void { void runtime.cancelCustodySeal(id).catch(() => {}); } #cancel = (): void => { this.#invalidate(); this.requestUpdate(); }; /** Checks captured handles before each asynchronous boundary. */ #current(binding: SealBinding): boolean { return this.isConnected && binding.generation === this.#generation && binding.runtime === this.runtime && binding.draft === this.draft && binding.terms === this.terms && binding.policy === this.policy && binding.sources === this.sources && binding.box === this.box; } /** Asks the worker for the checked preview and opens the dialog on it. */ #prepare = async (): Promise => { if (this.#busy) return; const { runtime, draft, terms, policy, sources, box } = this; if ( !runtime || !draft || !terms || !policy || !sources || !this.isConnected ) { return; } this.#invalidate(); const binding: SealBinding = { runtime, draft, terms, policy, sources, box, generation: this.#generation, }; this.#binding = binding; this.#busy = true; this.#error = ""; this.requestUpdate(); try { const preview = await runtime.prepareCustodySeal({ draft: draft.ref(), terms: terms.ref(), policy: policy.ref(), allowedSources: sources.ref(), ...(box === undefined ? {} : { box: box.ref() }), }); if (!this.#current(binding)) { this.#releasePreview(runtime, preview.id); return; } this.#preview = preview; this.#busy = false; this.requestUpdate(); await this.updateComplete; if (this.#current(binding)) { this.shadowRoot?.querySelector("dialog")?.showModal(); } } catch (error) { if (!this.#current(binding)) return; this.#busy = false; this.#error = error instanceof Error ? error.message : "The seal could not be prepared."; this.requestUpdate(); } }; /** Admits only a real browser gesture on the open host confirmation. */ #confirm = async (event: Event): Promise => { Event.prototype.stopPropagation.call(event); await this.#commitReviewed(event); }; /** Seals the reviewed value and announces it. */ async #commitReviewed(event: Event): Promise { if ( typeof MouseEvent === "undefined" || !(event instanceof MouseEvent) || !event.isTrusted || event.currentTarget !== this.shadowRoot?.querySelector("button.confirm") || !this.shadowRoot?.querySelector("dialog")?.open ) { return; } const binding = this.#binding; const preview = this.#preview; if (this.#busy || !binding || !preview || !this.#current(binding)) return; this.#busy = true; this.#error = ""; this.requestUpdate(); try { // The seal writes the link to the box into the box binding the actor // reviewed with, in the transaction that writes the entry, so the // entry and the room's way to it land together. const sealed = await binding.runtime.commitCustodySeal(preview.id); // The preview is consumed; nothing is left to cancel. this.#preview = undefined; if (!this.#current(binding)) return; this.#invalidate(); this.emit("cf-sealed", { instance: sealed.instance }); } catch (error) { this.#preview = undefined; if (!this.#current(binding)) return; this.#invalidate(); this.#error = error instanceof Error ? error.message : "The value could not be sealed."; } finally { this.requestUpdate(); } } }