From 93cc1e149b70a4f3db42379133964005652c47cf Mon Sep 17 00:00:00 2001 From: grjte Date: Fri, 3 Jul 2026 08:15:26 +0100 Subject: [PATCH 1/4] feat (iso): use automerge authors API to allowlist of iframe-created cos --- isolation/ISOLATION.md | 12 +- isolation/README.md | 23 ++ isolation/package.json | 5 +- isolation/src/boot/host/boot.ts | 23 +- isolation/src/boot/iframe/main.ts | 13 +- isolation/src/bridges/access-control.ts | 55 ++--- isolation/src/bridges/repo-bridge.ts | 236 +++++++++++++++----- pnpm-lock.yaml | 277 +++++++++++++++++------- 8 files changed, 480 insertions(+), 164 deletions(-) diff --git a/isolation/ISOLATION.md b/isolation/ISOLATION.md index 83f08b8c..9e8e9af7 100644 --- a/isolation/ISOLATION.md +++ b/isolation/ISOLATION.md @@ -114,8 +114,9 @@ Which parts of the UI end up inside the boundary is a choice of the frame config │ - unsigned in → signed out │ └─────────────────────────────────┘ -* unknown: prompt — documents not in the host repo are not auto-allowlisted; - the user is prompted via window.confirm — see the Allowlist section. +* unknown: prompt — a document not in the host repo is auto-allowlisted only if + every change is authored by the iframe's own author id (it created it); + otherwise the user is prompted via window.confirm — see the Allowlist section. ``` > **Not yet implemented (future / Keyhive):** the "Keyhive Isolation Identity" box, the "Signs 'signable' commits with isolation identity" line, and the sync-channel "'signed or signable' filter" / "unsigned in → signed out" notes describe the Keyhive-enabled design. Today the intermediary holds no isolation identity and signs nothing, and edits flow through unsigned. See [Security without Keyhive](#security-without-keyhive). @@ -216,7 +217,7 @@ The allowlist is seeded from the boot spec's `rootUrls`. It is expanded through 1. **Transitive discovery.** Each root document's content is scanned for embedded automerge URLs (recursively walking objects, arrays, and strings). All discovered URLs are added to the allowlist (unless denylisted). This reflects the assumption that if the user opened a document, its referenced children are authorized for the tool rendering it. -2. **User approval.** When the iframe requests a document that is not on the allowlist, the user is prompted via `window.confirm()` and can approve access explicitly. If the document is one the host repo already knows about, the allowlist is first refreshed (re-scanning all root documents for new URLs, e.g. a reference the user just typed) and the prompt is skipped if it now matches. Documents the host has never seen (newly created by the iframe, added by a collaborator, or embedded in the tool) skip that refresh — a root re-scan cannot surface a document the host has never seen — and prompt directly. Unknown documents are **not** auto-allowlisted; this prevents a tool from silently gaining access to any URL it constructs, at the cost of prompting for documents the iframe itself just created. Once the Author ID API is available, documents created by the iframe's own author ID will be auto-allowlisted while other unknown documents continue to prompt. _(See "Waiting on automerge/keyhive teams" below.)_ +2. **Iframe-authored auto-allowlist + user approval.** When the iframe requests a document that is not on the allowlist: if the document is one the host repo already knows about, the allowlist is first refreshed (re-scanning all root documents for new URLs, e.g. a reference the user just typed) and access is granted if it now matches. Otherwise the gate checks **authorship** — using the Author ID API, the iframe's `Repo` is configured with a per-isolation-context author id (generated host-side, passed into the iframe at boot), so changes the iframe's tools make are attributed to it. If **every** change in the requested document was authored by that id, the document is **auto-allowlisted with no prompt** — it is one the iframe just created. Any other unknown document (never seen by the host, or with any change by a different/absent author) **prompts the user** via `window.confirm()`. This preserves the safe default — a tool cannot silently gain access to a URL it constructs or to a foreign document — while removing the spurious prompt for a tool opening the document it just made. The all-changes check is conservative on purpose: a doc with even one foreign-authored (or unauthored) change still prompts, so a tool can't smuggle access by appending an own-authored change to someone else's document. (Reading the author materializes the doc in the trusted host repo only; the doc becomes iframe-syncable only after it is added to the allowlist, so materializing to check authorship does not itself grant access.) ### Keyhive integration (future) @@ -330,10 +331,7 @@ The modal renders a `` in the host DOM (outside the isolation bo These are API changes being developed by the automerge and keyhive teams. The isolation architecture depends on them but cannot implement them until they ship. -- **Author ID API.** Configure an author ID on the iframe's Repo so that edits made by tools are correctly attributed. The iframe repo will be configured with the isolation identity's author ID. (Not yet available on main.) Once available, improve the unknown-document handling in `onAccessRequest`: - - When the intermediary encounters an unknown document whose author matches the iframe's assigned author ID, auto-allowlist it (the iframe created it) instead of prompting. - - For unknown documents with a different author, keep prompting the user. - - Today, _all_ unknown documents prompt the user (the blanket auto-allowlist workaround has been removed); this change would restore silent access for the iframe's own creations only. +- **~~Author ID API~~ — IMPLEMENTED.** The iframe's `Repo` is now configured with a per-isolation-context author id (generated host-side, passed in at boot), and `onAccessRequest` auto-allowlists an unknown document iff every change is by that author id (otherwise prompts). See the "Iframe-authored auto-allowlist" item under [Allowlist](#allowlist). (Requires the automerge authors release — `automerge-repo ≥ 2.7.0-authors`, `automerge ≥ 3.4.0-rev-frag-hex`; `RepoConfig.authorId` + `ChangeMetadata.author`.) - **"Signed or signable" bridge config.** Configure the bridge connection (NetworkAdapter or similar) between the intermediary repo and the iframe repo to only accept signed or signable commits from the iframe direction. Signable commits are signed with the isolation identity; mis-attributed commits are dropped. (Not yet available.) ### Tracked separately diff --git a/isolation/README.md b/isolation/README.md index 396e275b..67dae603 100644 --- a/isolation/README.md +++ b/isolation/README.md @@ -95,6 +95,29 @@ props tweak (collapse toggle, tool reorder) updates in place. versions must match what the host serves (i.e. the other base modules' versions). Keep `package.json` in step with `threepane/package.json`. +**Requires the automerge authors release** (`@automerge/automerge-repo ≥ +2.7.0-authors`, `@automerge/automerge ≥ 3.4.0-rev-frag-hex`) for the author-id +feature below — `RepoConfig.authorId` + `ChangeMetadata.author`. The host import +map (patchwork-next catalog) must serve these too. + +## Iframe-authored documents auto-allowlist + +The iframe's `Repo` is configured with a per-isolation-context **author id** +(generated host-side in `bootIsolation`, passed into the iframe at boot, used as +both `peerId` and the repo's `authorId`). So every change the iframe's tools make +is attributed to that id. + +The access gate (`handleAccessRequest`) uses this to skip the permission prompt +for a document the tool **just created**: when the iframe requests an +unknown-but-not-denylisted document, the gate reads its change metadata and +**auto-allowlists it iff *every* change is authored by the iframe's own id**. +Any other unknown document (or one with a foreign/absent-author change) still +prompts via `window.confirm()`. The all-changes check is deliberately +conservative — one foreign change and it prompts — so a tool can't gain access to +someone else's document by appending an own-authored change. Reading the author +materializes the doc only in the trusted host repo; a doc becomes iframe-syncable +only once added to the allowlist, so the check itself grants nothing. + ## Registration Like every patchwork module, this is published/registered independently (not a diff --git a/isolation/package.json b/isolation/package.json index 0ab9c8d5..3d162276 100644 --- a/isolation/package.json +++ b/isolation/package.json @@ -21,8 +21,9 @@ "author": "Ink & Switch", "license": "MIT", "dependencies": { - "@automerge/automerge-repo": "^2.6.0-subduction.26", - "@automerge/automerge-repo-network-messagechannel": "^2.6.0-subduction.26", + "@automerge/automerge": "^3.4.0-rev-frag-hex.3", + "@automerge/automerge-repo": "^2.7.0-authors.1", + "@automerge/automerge-repo-network-messagechannel": "^2.7.0-authors.1", "@inkandswitch/patchwork-elements": "1.0.0", "@inkandswitch/patchwork-filesystem": "^0.0.8", "@inkandswitch/patchwork-plugins": "^0.0.11", diff --git a/isolation/src/boot/host/boot.ts b/isolation/src/boot/host/boot.ts index f89fb105..4f26d484 100644 --- a/isolation/src/boot/host/boot.ts +++ b/isolation/src/boot/host/boot.ts @@ -93,6 +93,16 @@ export function bootIsolation(host: HTMLElement): IsolationHandle { const rootComponentId = readRootComponentId(host); const rootUrls = readAllowlistUrls(host); + // The isolation context's identity, generated host-side so the access gate can + // compare against the same value the iframe repo authors with (no round-trip). + // - `authorId`: a hex id (UUID prefix — all hex chars) used as the iframe + // repo's Automerge author, so changes it makes are attributed to it. The + // gate auto-allowlists an unknown doc iff *every* change is by this author. + // - `peerId`: the same id, human-readable, for the iframe repo's peer id + + // logging. + const authorId = crypto.randomUUID().slice(0, 8); + const isolationPeerId = `isolation-${authorId}`; + // State wired up during the boot, torn down together. All start empty, so // teardown() before/during boot is a safe no-op over them. const cleanups: Array<() => void> = []; @@ -101,7 +111,10 @@ export function bootIsolation(host: HTMLElement): IsolationHandle { let iframe: HTMLIFrameElement | null = null; async function run() { - log(`init root "${rootComponentId}" with ${rootUrls.length} root URLs`); + log( + `init root "${rootComponentId}" with ${rootUrls.length} root URLs, ` + + `authorId=${authorId}` + ); const repo = getRepo(host); if (!repo) return; @@ -134,6 +147,9 @@ export function bootIsolation(host: HTMLElement): IsolationHandle { allowlist, hostRepo: repo, denylist, + // The iframe repo authors its changes with this id (see the boot message); + // the intermediary auto-allowlists docs solely authored by it. + iframeAuthorId: authorId, onAccessRequest: (documentId) => handleAccessRequest(repo, rootUrls, allowlist, denylist, documentId), }); @@ -226,6 +242,11 @@ export function bootIsolation(host: HTMLElement): IsolationHandle { // change between boot start and this async send is reflected in the // initial boot rather than lost, since a pre-port push no-ops. rootComponentData: readRootComponentData(host), + // The iframe repo's identity, generated host-side (see bootIsolation). + // `authorId` attributes the iframe's changes so the access gate can + // recognize docs it created; `peerId` is the readable form. + authorId, + peerId: isolationPeerId, registryEntries, esmsSource: assets.esmsSource, hostStyles: assets.hostStyles, diff --git a/isolation/src/boot/iframe/main.ts b/isolation/src/boot/iframe/main.ts index 7fb7155a..eecdd190 100644 --- a/isolation/src/boot/iframe/main.ts +++ b/isolation/src/boot/iframe/main.ts @@ -44,6 +44,10 @@ interface InitMessage { syncPort: MessagePort; data: { rootComponentId: string; + /** The iframe repo's Automerge author id (hex), generated host-side. */ + authorId: string; + /** The iframe repo's peer id (readable form of the author id), host-side. */ + peerId: string; /** * Opaque JSON data for the root component, relayed verbatim from the host * (the boundary never parses it). Materialized as-is into the root's inert @@ -107,6 +111,9 @@ export async function boot(deps: BootDeps) { // until modules load, but we need logging during bootstrap. // Respects the same localStorage("debug") namespace convention. const NAMESPACE = "patchwork:elements:isolation:iframe"; + // A local id used only to tag early console output (before the boot message + // arrives). The repo's real identity — peerId + authorId — comes from the boot + // message (host-generated), so the access gate can compare against it. const peerId = "isolation-" + crypto.randomUUID().slice(0, 8); let debugEnabled = false; const log = (...args: unknown[]) => { @@ -273,8 +280,12 @@ export async function boot(deps: BootDeps) { const syncAdapter = new messagechannel.MessageChannelNetworkAdapter( init.syncPort ); + // Identity is generated host-side (so the access gate compares against the + // same value): `authorId` attributes this repo's changes as the isolation + // context, letting the host auto-allowlist docs the iframe created. const repo = new automergeRepo.Repo({ - peerId: peerId, + peerId: d.peerId as any, + authorId: d.authorId, network: [syncAdapter], }); (window as any).repo = repo; diff --git a/isolation/src/bridges/access-control.ts b/isolation/src/bridges/access-control.ts index 702795c6..0274cf2b 100644 --- a/isolation/src/bridges/access-control.ts +++ b/isolation/src/bridges/access-control.ts @@ -159,19 +159,16 @@ async function refreshAllowlistFromRoots( } /** - * Decide whether the iframe may access a document that isn't yet on the - * allowlist — the intermediary repo's `onAccessRequest` gate. + * Prompt the user to grant the iframe access to a document that isn't on the + * allowlist and wasn't auto-allowlisted (i.e. not solely authored by the iframe; + * the author check lives in the intermediary's `document` watcher — see + * repo-bridge). Reached as the fallback for docs the iframe references but did + * not itself create. * - * Unknown documents are NOT auto-allowlisted; the user is prompted. This is a - * safe default: it stops a tool from silently gaining access to any URL it - * constructs. The cost is that documents the iframe itself just created also - * prompt. - * - * TODO: once the Author ID API is available, auto-allowlist unknown documents - * whose author matches the iframe's assigned author ID (the iframe created - * them) and continue to prompt for all others. - * - * Returns true (and allowlists the doc) if access is granted. + * If the document is one the host repo already knows about, the allowlist is + * first refreshed (re-scanning roots for newly-added references) and the prompt + * is skipped if it now matches. Returns true (and allowlists) if access is + * granted. */ export async function handleAccessRequest( repo: Repo, @@ -180,28 +177,32 @@ export async function handleAccessRequest( denylist: SyncDenylist, documentId: DocumentId ): Promise { + log(`handling access request for ${documentId}`); + let approved = false; if (repo.handles[documentId]) { // Known to the host but not yet allowlisted — the URL may have been added // since the initial scan (e.g. the user typed a new reference), so re-scan - // roots before asking. (Skipped for unknown docs: a root re-scan can't - // surface a doc the host has never seen, so it would be wasted work.) + // roots before asking. await refreshAllowlistFromRoots(repo, rootUrls, allowlist, denylist); if (allowlist.has(documentId)) return true; + + approved = window.confirm( + `A tool wants to access a document on your system:\n\n` + + `Document ID: ${documentId}\n\n` + + `Allow access?` + ); + } else { + approved = window.confirm( + `A tool wants to access a remote document:\n\n` + + `Document ID: ${documentId}\n\n` + + `This document was not created by the tool and is not on your local system. Allow access?` + ); } - // TODO: remove temp approval - // const approved = window.confirm( - // `A tool wants to access a document:\n\n` + - // `Document ID: ${documentId}\n\n` + - // `This may be a document the tool just created, or one it is ` + - // `trying to open. Allow access?` - // ); - // if (approved) { - // allowlist.addDocumentId(documentId); - // } - // return approved; - allowlist.addDocumentId(documentId); - return true; + if (approved) { + allowlist.addDocumentId(documentId); + } + return approved; } /** diff --git a/isolation/src/bridges/repo-bridge.ts b/isolation/src/bridges/repo-bridge.ts index 3da09687..906a2195 100644 --- a/isolation/src/bridges/repo-bridge.ts +++ b/isolation/src/bridges/repo-bridge.ts @@ -16,8 +16,10 @@ import { type AutomergeUrl, type PeerId, type DocumentId, + type DocHandle, parseAutomergeUrl, } from "@automerge/automerge-repo"; +import { getChangesMetaSince } from "@automerge/automerge"; import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel"; import { log } from "../log.js"; @@ -94,15 +96,23 @@ export class SyncDenylist extends SyncDocumentSet { } export interface IntermediaryRepoOptions { - /** The allowlist controlling which documents can sync to the iframe. */ + /** The allowlist controlling which documents can sync to the host. */ allowlist: SyncAllowlist; /** The host Repo to sync allowed documents from. */ hostRepo: Repo; /** Optional denylist — denylisted documents are blocked regardless of allowlist. */ denylist?: SyncDenylist; /** - * Called when the iframe requests a document that's not on the allowlist - * or denylist. If it returns true, the document is added to the allowlist. + * The iframe repo's Automerge author id (generated host-side). A document + * arriving from the iframe whose changes are *all* authored by this id was + * created by the iframe itself and is auto-allowlisted (no prompt). + */ + iframeAuthorId: string; + /** + * Called (out of band, never from the sync gate) for a document the iframe + * requests that isn't allowlisted and wasn't created by the iframe — i.e. a + * foreign document that did not arrive over the open iframe channel. If it + * returns true, the document is added to the allowlist. */ onAccessRequest?: (documentId: DocumentId) => Promise; } @@ -122,14 +132,27 @@ export interface IntermediaryRepo { * 1. hostChannel — connects intermediary ↔ host repo * 2. iframeChannel — connects intermediary ↔ iframe repo * - * The intermediary's `shareConfig` gates document sync: - * - Only allowlisted documents are accepted from any peer (including the host) - * - Only allowlisted documents are announced to the iframe peer + * The intermediary's `shareConfig` gates sync ASYMMETRICALLY by channel: + * - Host channel: denylist THEN allowlist — a doc crosses only if it is not + * denylisted AND is allowlisted. This is the security boundary; nothing + * denylisted or un-allowlisted enters/leaves the host graph. + * - Iframe channel: fully open — all docs sync both ways. Safe because the + * intermediary is ephemeral and its only other peer (the host) is gated, so + * it can only ever hold allowlisted docs + docs the iframe itself pushed. A + * denylisted doc can never enter the intermediary to reach the iframe. + * + * A document the iframe references that isn't yet allowlisted is DENIED by the + * gate immediately and resolved out of band (see `resolveAccess` below): if the + * iframe created it (it is resident and authored solely by the iframe) it is + * auto-allowlisted; otherwise the user is prompted. Once decided, the gate is + * re-run via `repo.shareConfigChanged()`. The gate itself never blocks on a + * fetch or a prompt — doing so would stall the sync (see `hostChannelAllows`). */ export function createIntermediaryRepo( options: IntermediaryRepoOptions ): IntermediaryRepo { - const { allowlist, hostRepo, denylist, onAccessRequest } = options; + const { allowlist, hostRepo, denylist, iframeAuthorId, onAccessRequest } = + options; const hostRepoPeerId = hostRepo.peerId; @@ -155,14 +178,47 @@ export function createIntermediaryRepo( // repo exists, so on the normal path this never fires. // - "denylisted": the document is in the protected set. // The host peer is never gated here; only iframe-bound sync is. - const denylistBlock = ( + // + // Whether a document may cross the HOST channel: it must NOT be denylisted and + // MUST be allowlisted. This gate is a *fast, synchronous-return policy check* + // over in-memory sets — it never awaits a document fetch or a user prompt. + // automerge-repo holds a peer at sharePolicyState "loading" for as long as + // `access` is unresolved, which would stall the sync (no doc-unavailable, no + // content) — so any slow or interactive decision must happen out of band. An + // undecided document is DENIED now; `resolveAccess` decides it in the + // background and, once decided, calls `repo.shareConfigChanged()` to re-run + // this gate and re-engage the waiting iframe peer (see the automerge team's + // "immediately deny, prompt, then fire shareConfigChanged" guidance). + // + // While the denylist is still populating we fail closed (allow nothing to/from + // the host) — the boot path awaits denylist readiness before this repo exists, + // so this is defense in depth. This is the single security boundary; the + // iframe channel is open. + const hostChannelAllows = (documentId: DocumentId): boolean => { + if (denylist && !denylist.isReady) return false; + if (denylist?.has(documentId)) return false; + if (allowlist.has(documentId)) return true; + // Already decided-no, or a decision is already in flight: deny without + // re-prompting / re-resolving (this gate is called repeatedly per re-eval). + if (denied.has(documentId)) return false; + if (pending.has(documentId)) return false; + + // Undecided: kick off out-of-band resolution and deny for now. + pending.add(documentId); + void resolveAccess(documentId); + return false; + }; + + // Per-channel gate. The iframe channel is fully open (all docs sync both + // ways); the host channel enforces denylist-then-allowlist. Both `access` + // (bidirectional gate) and `announce` (proactive push) use the same rule. + const shareGate = async ( peerId: PeerId, - documentId: DocumentId - ): "not-ready" | "denylisted" | null => { - if (peerId === hostRepoPeerId) return null; - if (denylist && !denylist.isReady) return "not-ready"; - if (denylist?.has(documentId)) return "denylisted"; - return null; + documentId?: DocumentId + ): Promise => { + if (!documentId) return peerId !== hostRepoPeerId; + if (peerId !== hostRepoPeerId) return true; // iframe channel: open + return hostChannelAllows(documentId); // host channel: denylist then allowlist }; const repo = new Repo({ @@ -170,45 +226,8 @@ export function createIntermediaryRepo( network: [hostAdapter, iframeAdapter], isEphemeral: true, shareConfig: { - announce: async (peerId: PeerId, documentId?: DocumentId) => { - if (!documentId) return true; - if (denylistBlock(peerId, documentId)) return false; - return allowlist.has(documentId); - }, - access: async (peerId: PeerId, documentId?: DocumentId) => { - if (!documentId) return false; - const blocked = denylistBlock(peerId, documentId); - if (blocked === "not-ready") { - log(`access ${documentId} BLOCKED (denylist not ready)`); - return false; - } - if (blocked === "denylisted") { - log(`access ${documentId} DENIED`); - return false; - } - if (allowlist.has(documentId)) return true; - - // Not allowlisted, not denylisted — prompt the user if this - // is an iframe request and we have a callback - if (peerId !== hostRepoPeerId && onAccessRequest) { - log(`access ${documentId} prompting user`); - const approved = await onAccessRequest(documentId); - if (approved) { - log(`access ${documentId} APPROVED by user`); - // Re-evaluate share config so previously-denied peers - // pick up the new allowlist entry - repo.shareConfigChanged(); - return true; - } - log(`access ${documentId} REJECTED by user`); - return false; - } - - if (peerId !== hostRepoPeerId) { - log(`access ${documentId} BLOCKED`); - } - return false; - }, + announce: shareGate, + access: shareGate, }, }); @@ -221,6 +240,117 @@ export function createIntermediaryRepo( ); hostRepo.networkSubsystem.addNetworkAdapter(isolationHostAdapter); + // ── Out-of-band access resolution ──────────────────────────── + // The gate (hostChannelAllows) denies any undecided document immediately and + // enqueues it here. `pending` dedupes in-flight resolutions (the gate is + // called repeatedly as the sync layer re-evaluates); `denied` remembers a + // user's "no" so we never re-prompt for the same document. + const pending = new Set(); + const denied = new Set(); + + // Whether every change in a *resident* document was authored by the iframe. + // Reads the already-ready handle directly — it must never trigger a fetch (a + // fetch would re-enter the host-channel gate and deadlock), so the caller is + // responsible for only passing a handle that is already `ready`. The + // length > 0 guard is conservative on purpose: an empty doc doesn't + // auto-allow, it falls through to the prompt. + const isAuthoredSolelyByIframe = (handle: DocHandle): boolean => { + try { + const doc = handle.doc(); + if (!doc) return false; + const metas = getChangesMetaSince(doc, []); + return ( + metas.length > 0 && metas.every((m) => m.author === iframeAuthorId) + ); + } catch (err) { + log(`author check for ${handle.documentId} threw`, err); + return false; + } + }; + + // Decide an undecided document without blocking the gate, then re-run the gate + // via shareConfigChanged(). + // + // Classification is deterministic and event-driven (no timeout): because the + // gate currently denies the host peer for this document, the host source + // cannot supply it during this window. So we watch the intermediary's own + // query for this document — + // - it reaches `ready` ⇒ the content came from the IFRAME (the open channel) + // ⇒ the iframe created it ⇒ auto-allowlist iff every change is iframe- + // authored (else fall through to the prompt); + // - it settles `unavailable` ⇒ neither the iframe nor the (gated) host has it + // ⇒ it is a FOREIGN document the iframe is requesting ⇒ prompt the user. + const resolveAccess = async (documentId: DocumentId): Promise => { + const finish = (allow: boolean, reason: string) => { + if (allow) { + allowlist.addDocumentId(documentId); + log(`access ${documentId} allowed (${reason})`); + } else { + denied.add(documentId); + log(`access ${documentId} denied (${reason})`); + } + pending.delete(documentId); + // Re-run the (now-decided) gate for every doc and re-engage the iframe + // peer, which was marked hasRequested when the first denial sent it + // doc-unavailable. + repo.shareConfigChanged(); + }; + + // Prompt for a foreign document, then finish. + const prompt = async () => { + if (!onAccessRequest) { + finish(false, "no onAccessRequest handler"); + return; + } + log(`access ${documentId} not allowlisted; prompting user`); + let approved = false; + try { + approved = await onAccessRequest(documentId); + } catch (err) { + log(`onAccessRequest for ${documentId} threw`, err); + } + finish(approved, approved ? "user approved" : "user denied"); + }; + + const progress = repo.findWithProgress(documentId); + + // Act exactly once, on the first terminal state. `settled` guards against + // both a synchronous subscribe callback (before `unsubscribe` is assigned) + // and any later transition after we've already decided. + let settled = false; + const decide = (state: ReturnType): boolean => { + if (settled) return true; + if (state.state === "ready") { + settled = true; + if (isAuthoredSolelyByIframe(state.handle)) { + finish(true, "authored by iframe"); + } else { + // Resident but not iframe-authored — treat as foreign and prompt. + void prompt(); + } + return true; + } + if (state.state === "unavailable") { + settled = true; + void prompt(); + return true; + } + if (state.state === "failed") { + settled = true; + finish(false, `query failed: ${state.error}`); + return true; + } + return false; // still loading + }; + + // Act on the current state if already terminal, otherwise on the first + // terminal transition. + if (decide(progress.peek())) return; + const unsubscribe = progress.subscribe((state) => { + if (decide(state)) unsubscribe(); + }); + }; + return { iframePort: iframeChannel.port2, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 075b6c5d..3bb689bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 1.9.12(@babel/core@7.28.4(supports-color@5.5.0))(solid-js@1.9.13) solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) solid-js: specifier: ^1.9.9 version: 1.9.13 @@ -115,7 +115,7 @@ importers: version: 2.1.1 solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) solid-js: specifier: ^1.9.9 version: 1.9.13 @@ -183,7 +183,7 @@ importers: devDependencies: '@chee/patchwork-bundles': specifier: ^0.2.0 - version: 0.2.0(@automerge/automerge@3.2.6)(solid-js@1.9.13) + version: 0.2.0(@automerge/automerge@3.2.6)(solid-js@1.9.13)(supports-color@5.5.0) '@inkandswitch/patchwork-bootloader': specifier: ^0.0.3 version: 0.0.3(@automerge/automerge-repo@2.5.6(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@keyhive/keyhive@0.0.0-alpha.40)(supports-color@5.5.0) @@ -228,7 +228,7 @@ importers: version: 0.2.3(@automerge/automerge-repo-solid-primitives@2.6.0-subduction.39(@automerge/automerge@3.3.0-fragments.1)(solid-js@1.9.13))(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) solid-js: specifier: ^1.9.9 version: 1.9.13 @@ -396,7 +396,7 @@ importers: version: 0.0.7(@automerge/automerge@3.3.0-fragments.1)(@codemirror/state@6.7.0)(@codemirror/view@6.43.2)(solid-js@1.9.13) solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) solid-js: specifier: ^1.9.9 version: 1.9.13 @@ -516,7 +516,7 @@ importers: version: 0.2.3(@automerge/automerge-repo-solid-primitives@2.6.0-subduction.39(@automerge/automerge@3.3.0-fragments.1)(solid-js@1.9.13))(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) solid-js: specifier: ^1.9.9 version: 1.9.13 @@ -702,7 +702,7 @@ importers: version: 0.0.6(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.2.6)) solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) solid-js: specifier: ^1.9.9 version: 1.9.13 @@ -766,7 +766,7 @@ importers: version: 0.2.3(@automerge/automerge-repo-solid-primitives@2.6.0-subduction.39(@automerge/automerge@3.3.0-fragments.1)(solid-js@1.9.13))(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) solid-js: specifier: ^1.9.10 version: 1.9.13 @@ -801,24 +801,27 @@ importers: isolation: dependencies: + '@automerge/automerge': + specifier: ^3.4.0-rev-frag-hex.3 + version: 3.4.0-rev-frag-hex.3 '@automerge/automerge-repo': - specifier: ^2.6.0-subduction.26 - version: 2.6.0-subduction.34(supports-color@5.5.0) + specifier: ^2.7.0-authors.1 + version: 2.7.0-authors.1 '@automerge/automerge-repo-network-messagechannel': - specifier: ^2.6.0-subduction.26 - version: 2.6.0-subduction.34(supports-color@5.5.0) + specifier: ^2.7.0-authors.1 + version: 2.7.0-authors.1 '@inkandswitch/patchwork-elements': specifier: 1.0.0 - version: 1.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.6.0-subduction.34))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)(supports-color@5.5.0) + version: 1.0.0(@automerge/automerge-repo@2.7.0-authors.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)))(@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.7.0-authors.1))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13) '@inkandswitch/patchwork-filesystem': specifier: ^0.0.8 - version: 0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(supports-color@5.5.0) + version: 0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3) '@inkandswitch/patchwork-plugins': specifier: ^0.0.11 - version: 0.0.11(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)) + version: 0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)) '@inkandswitch/patchwork-providers': specifier: 0.2.2 - version: 0.2.2(@automerge/automerge-repo@2.6.0-subduction.34) + version: 0.2.2(@automerge/automerge-repo@2.7.0-authors.1) debug: specifier: ^4.4.3 version: 4.4.3(supports-color@5.5.0) @@ -828,7 +831,7 @@ importers: devDependencies: '@inkandswitch/patchwork-bootloader': specifier: ^0.2.8 - version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29)(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13) + version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@automerge/vanillajs@2.6.0-subduction.29)(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13) typescript: specifier: ^5.6.0 version: 5.9.3 @@ -849,7 +852,7 @@ importers: version: 0.0.6(@automerge/automerge-repo@2.6.0-subduction.34)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.2.6))(@inkandswitch/patchwork-plugins@0.0.6(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.4(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.2.6))) solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) devDependencies: '@automerge/automerge': specifier: ^3.2.5 @@ -949,7 +952,7 @@ importers: version: 4.4.3(supports-color@5.5.0) solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) solid-js: specifier: ^1.9.9 version: 1.9.13 @@ -1126,10 +1129,10 @@ importers: version: 6.43.2 '@inkandswitch/patchwork-bootloader': specifier: ^0.2.4 - version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29)(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13) + version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29)(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13) '@inkandswitch/patchwork-plugins': specifier: ^0.0.11 - version: 0.0.11(@automerge/automerge-repo@2.6.0-subduction.39)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.1.2(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.2)) + version: 0.0.11(@automerge/automerge-repo@2.6.0-subduction.39)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.1.2(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)) '@lezer/highlight': specifier: ^1.2.1 version: 1.2.3 @@ -1187,7 +1190,7 @@ importers: version: 4.3.5(solid-js@1.9.13) solid-automerge: specifier: ^2.0.0 - version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13) + version: 2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0) solid-js: specifier: ^1.9.10 version: 1.9.13 @@ -1371,6 +1374,10 @@ packages: resolution: {integrity: sha512-fW1/75QNhYtuAGey/KhlZksxcFNksYNzAi/S4aU5tgLqHqQkWXPp2ndvbUC7t3cYPKxlf6M/TsnPbmmoO60dRw==} engines: {node: '>=22.13'} + '@automerge/automerge-repo-network-messagechannel@2.7.0-authors.1': + resolution: {integrity: sha512-x8r9F1AHVO87RRwBrzR+xbJyYxTv3+zkYOpZzi49eMrCuKRX0Ufp6m1Qmo0a3f5dRPbDnv8WNfNSpNUlW9YNNw==} + engines: {node: '>=22.13'} + '@automerge/automerge-repo-network-websocket@2.5.0': resolution: {integrity: sha512-zrwbkW8hk3fWkXdeaH+YDfGG63WaB9WLfWYyinZx2AQv4mr6D6UhIhviIjweMmRxMLen6NlIcdu3toHV33ZuKg==} @@ -1490,6 +1497,10 @@ packages: resolution: {integrity: sha512-9r3GS8FtZKoIJOtJqkQR0ukF7HSu+IyV5Iqf7MP3ELDz/TEaUbZV8ysyl5Ju4fVZjmagI/n3oH2h4ySk4FdEFQ==} engines: {node: '>=22.13'} + '@automerge/automerge-repo@2.7.0-authors.1': + resolution: {integrity: sha512-6yN94JYJoLp3IhkfMHnsttLxpx4/99GrXBZzfQ4F2+tHBxpj+AvgXJs1rBynW/apUVyhrYrS6WfmdNNf5zT7dA==} + engines: {node: '>=22.13'} + '@automerge/automerge-subduction@0.12.1': resolution: {integrity: sha512-s1s40RTozkqExIk1LYdogKPzpCMFfe/7KBS0ccAlwsy4JwdcUGG9NQO2Pm3kZq8vD3OPw7DcIOSPG+bnLESQFg==} @@ -1523,6 +1534,9 @@ packages: '@automerge/automerge@3.3.0-fragments.2': resolution: {integrity: sha512-F5KL+YXD0GMFH/as0/kJ3+lotpuJsH1SSrhhKdYSGtR+S9ytBNTw+s3pAjF2Vr0+ZdTMHxsGjqj5SRkJiSNokQ==} + '@automerge/automerge@3.4.0-rev-frag-hex.3': + resolution: {integrity: sha512-EmFMwRWvzWrumg3pwVHqA0ZNhskFcTz3Fdfjwz89ktqXTvbvxwo7xQcA3pI0fTtHUuQE2JFfaZXW+Z7rxDKPyw==} + '@automerge/react@2.6.0-subduction.34': resolution: {integrity: sha512-CKB65u1gSHXTwkyro1/8UmDcNlR4UMHdqrXgbtLgvYff3U0Guptvh1jW8n0+ZIIwJDDTtktSiaBN6TzCpALViw==} engines: {node: '>=22.13'} @@ -5754,6 +5768,15 @@ snapshots: - supports-color - utf-8-validate + '@automerge/automerge-repo-network-messagechannel@2.7.0-authors.1': + dependencies: + '@automerge/automerge-repo': 2.7.0-authors.1 + eventemitter3: 5.0.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@automerge/automerge-repo-network-websocket@2.5.0': dependencies: '@automerge/automerge-repo': 2.5.0 @@ -6170,7 +6193,7 @@ snapshots: - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.40': + '@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0)': dependencies: '@automerge/automerge': 3.3.0-fragments.2 '@automerge/automerge-subduction': 0.16.0 @@ -6188,6 +6211,24 @@ snapshots: - supports-color - utf-8-validate + '@automerge/automerge-repo@2.7.0-authors.1': + dependencies: + '@automerge/automerge': 3.4.0-rev-frag-hex.3 + '@automerge/automerge-subduction': 0.16.0 + bs58check: 4.0.0 + cbor-x: 1.6.4 + debug: 4.4.3(supports-color@5.5.0) + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.0) + uuid: 14.0.1 + ws: 8.21.0 + xstate: 5.32.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@automerge/automerge-subduction@0.12.1': {} '@automerge/automerge-subduction@0.13.0': {} @@ -6212,6 +6253,8 @@ snapshots: '@automerge/automerge@3.3.0-fragments.2': {} + '@automerge/automerge@3.4.0-rev-frag-hex.3': {} + '@automerge/react@2.6.0-subduction.34(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) @@ -6490,11 +6533,11 @@ snapshots: '@cbor-extract/cbor-extract-win32-x64@2.2.2': optional: true - '@chee/patchwork-bundles@0.2.0(@automerge/automerge@3.2.6)(solid-js@1.9.13)': + '@chee/patchwork-bundles@0.2.0(@automerge/automerge@3.2.6)(solid-js@1.9.13)(supports-color@5.5.0)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.40 - '@inkandswitch/patchwork-bootloader': 0.3.2(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)(solid-js@1.9.13) - '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6) + '@automerge/automerge-repo': 2.6.0-subduction.40(supports-color@5.5.0) + '@inkandswitch/patchwork-bootloader': 0.3.2(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)(solid-js@1.9.13) + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6) es-module-lexer: 2.3.0 resolve.exports: 2.0.3 unplugin: 2.3.11 @@ -7364,20 +7407,48 @@ snapshots: - supports-color - utf-8-validate - '@inkandswitch/patchwork-bootloader@0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29)(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)': + '@inkandswitch/patchwork-bootloader@0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29)(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)': dependencies: '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-repo': 2.6.0-subduction.40 + '@automerge/automerge-repo': 2.7.0-authors.1 + '@automerge/automerge-repo-keyhive': 0.3.0-alpha.sub.1c(ws@8.21.0) + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.29 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.29 + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.29 + '@automerge/automerge-subduction': 0.15.0 + '@automerge/vanillajs': 2.6.0-subduction.29 + '@inkandswitch/patchwork-elements': 2.0.0(@automerge/automerge-repo@2.7.0-authors.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.7.0-authors.1))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13) + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.7.0-authors.1) + '@keyhive/keyhive': 0.0.0-alpha.56 + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@5.5.0) + resolve.exports: 2.0.3 + service-worker-types: '@types/serviceworker@0.0.153' + tinyargs: 0.1.4 + transitivePeerDependencies: + - '@types/react' + - bufferutil + - react + - solid-js + - supports-color + - utf-8-validate + + '@inkandswitch/patchwork-bootloader@0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.0))(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@automerge/vanillajs@2.6.0-subduction.29)(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)': + dependencies: + '@automerge/automerge': 3.4.0-rev-frag-hex.3 + '@automerge/automerge-repo': 2.7.0-authors.1 '@automerge/automerge-repo-keyhive': 0.3.0-alpha.sub.1c(ws@8.21.0) '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.29 '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.29 '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.29 '@automerge/automerge-subduction': 0.15.0 '@automerge/vanillajs': 2.6.0-subduction.29 - '@inkandswitch/patchwork-elements': 2.0.0(@automerge/automerge-repo@2.6.0-subduction.40)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.40))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13) - '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1) - '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)) - '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.40) + '@inkandswitch/patchwork-elements': 2.0.0(@automerge/automerge-repo@2.7.0-authors.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.7.0-authors.1))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13) + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.7.0-authors.1) '@keyhive/keyhive': 0.0.0-alpha.56 '@types/debug': 4.1.13 debug: 4.4.3(supports-color@5.5.0) @@ -7448,18 +7519,18 @@ snapshots: - supports-color - utf-8-validate - '@inkandswitch/patchwork-bootloader@0.3.2(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)(solid-js@1.9.13)': + '@inkandswitch/patchwork-bootloader@0.3.2(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)(solid-js@1.9.13)': dependencies: '@automerge/automerge': 3.2.6 - '@automerge/automerge-repo': 2.6.0-subduction.40 + '@automerge/automerge-repo': 2.6.0-subduction.40(supports-color@5.5.0) '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.39 '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.39 '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.39 '@automerge/automerge-subduction': 0.16.0 - '@inkandswitch/patchwork-elements': 2.0.0(@automerge/automerge-repo@2.6.0-subduction.40)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.40))(solid-js@1.9.13) - '@inkandswitch/patchwork-filesystem': 0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6) - '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)) - '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.40) + '@inkandswitch/patchwork-elements': 2.0.0(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0)))(solid-js@1.9.13) + '@inkandswitch/patchwork-filesystem': 0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0)) '@keyhive/keyhive': 0.1.0-alpha.5 '@types/debug': 4.1.13 debug: 4.4.3(supports-color@5.5.0) @@ -7558,6 +7629,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@inkandswitch/patchwork-elements@1.0.0(@automerge/automerge-repo@2.7.0-authors.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)))(@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.7.0-authors.1))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)': + dependencies: + '@automerge/automerge-repo': 2.7.0-authors.1 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)) + '@inkandswitch/patchwork-providers': 0.2.2(@automerge/automerge-repo@2.7.0-authors.1) + debug: 4.4.3(supports-color@5.5.0) + optionalDependencies: + '@types/react': 18.3.31 + react: 18.3.1 + solid-js: 1.9.13 + transitivePeerDependencies: + - supports-color + '@inkandswitch/patchwork-elements@2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.2.6))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.34)(@automerge/automerge@3.2.6)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.34))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) @@ -7628,12 +7713,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-elements@2.0.0(@automerge/automerge-repo@2.6.0-subduction.40)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.40))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)': + '@inkandswitch/patchwork-elements@2.0.0(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0)))(solid-js@1.9.13)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.40(supports-color@5.5.0) + '@inkandswitch/patchwork-filesystem': 0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0)) + debug: 4.4.3(supports-color@5.5.0) + optionalDependencies: + solid-js: 1.9.13 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-elements@2.0.0(@automerge/automerge-repo@2.7.0-authors.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.7.0-authors.1))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.40 - '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1) - '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)) - '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.40) + '@automerge/automerge-repo': 2.7.0-authors.1 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.7.0-authors.1) debug: 4.4.3(supports-color@5.5.0) optionalDependencies: '@types/react': 18.3.31 @@ -7642,14 +7739,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-elements@2.0.0(@automerge/automerge-repo@2.6.0-subduction.40)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.40))(solid-js@1.9.13)': + '@inkandswitch/patchwork-elements@2.0.0(@automerge/automerge-repo@2.7.0-authors.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.7.0-authors.1))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.13)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.40 - '@inkandswitch/patchwork-filesystem': 0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6) - '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)) - '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.40) + '@automerge/automerge-repo': 2.7.0-authors.1 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.7.0-authors.1) debug: 4.4.3(supports-color@5.5.0) optionalDependencies: + '@types/react': 18.3.31 + react: 18.3.1 solid-js: 1.9.13 transitivePeerDependencies: - supports-color @@ -7742,10 +7841,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)': + '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)': dependencies: '@automerge/automerge': 3.2.6 - '@automerge/automerge-repo': 2.6.0-subduction.40 + '@automerge/automerge-repo': 2.6.0-subduction.40(supports-color@5.5.0) '@types/debug': 4.1.13 '@types/node': 20.19.43 debug: 4.4.3(supports-color@5.5.0) @@ -7753,10 +7852,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)': + '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)': dependencies: '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-repo': 2.6.0-subduction.40 + '@automerge/automerge-repo': 2.7.0-authors.1 + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + debug: 4.4.3(supports-color@5.5.0) + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)': + dependencies: + '@automerge/automerge': 3.4.0-rev-frag-hex.3 + '@automerge/automerge-repo': 2.7.0-authors.1 '@types/debug': 4.1.13 '@types/node': 20.19.43 debug: 4.4.3(supports-color@5.5.0) @@ -7786,10 +7896,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)': + '@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)': dependencies: '@automerge/automerge': 3.2.6 - '@automerge/automerge-repo': 2.6.0-subduction.40 + '@automerge/automerge-repo': 2.6.0-subduction.40(supports-color@5.5.0) '@types/debug': 4.1.13 '@types/node': 20.19.43 debug: 4.4.3(supports-color@5.5.0) @@ -7830,10 +7940,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-filesystem@0.1.2(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.2)': + '@inkandswitch/patchwork-filesystem@0.1.2(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)': dependencies: - '@automerge/automerge': 3.3.0-fragments.2 - '@automerge/automerge-repo': 2.6.0-subduction.40 + '@automerge/automerge': 3.4.0-rev-frag-hex.3 + '@automerge/automerge-repo': 2.7.0-authors.1 '@types/debug': 4.1.13 '@types/node': 20.19.43 debug: 4.4.3(supports-color@5.5.0) @@ -7906,11 +8016,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.39)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.1.2(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.2))': + '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.39)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.1.2(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3))': dependencies: '@automerge/automerge': 3.3.0-fragments.1 '@automerge/automerge-repo': 2.6.0-subduction.39 - '@inkandswitch/patchwork-filesystem': 0.1.2(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.2) + '@inkandswitch/patchwork-filesystem': 0.1.2(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3) '@types/debug': 4.1.13 '@types/node': 20.19.43 debug: 4.4.3(supports-color@5.5.0) @@ -7919,11 +8029,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6))': + '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6)(@inkandswitch/patchwork-filesystem@0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6))': dependencies: '@automerge/automerge': 3.2.6 - '@automerge/automerge-repo': 2.6.0-subduction.40 - '@inkandswitch/patchwork-filesystem': 0.1.1(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.2.6) + '@automerge/automerge-repo': 2.6.0-subduction.40(supports-color@5.5.0) + '@inkandswitch/patchwork-filesystem': 0.1.1(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))(@automerge/automerge@3.2.6) '@types/debug': 4.1.13 '@types/node': 20.19.43 debug: 4.4.3(supports-color@5.5.0) @@ -7932,11 +8042,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1))': + '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1))': dependencies: '@automerge/automerge': 3.3.0-fragments.1 - '@automerge/automerge-repo': 2.6.0-subduction.40 - '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.40)(@automerge/automerge@3.3.0-fragments.1) + '@automerge/automerge-repo': 2.7.0-authors.1 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.3.0-fragments.1) + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + debug: 4.4.3(supports-color@5.5.0) + eventemitter3: 5.0.4 + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3))': + dependencies: + '@automerge/automerge': 3.4.0-rev-frag-hex.3 + '@automerge/automerge-repo': 2.7.0-authors.1 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.7.0-authors.1)(@automerge/automerge@3.4.0-rev-frag-hex.3) '@types/debug': 4.1.13 '@types/node': 20.19.43 debug: 4.4.3(supports-color@5.5.0) @@ -8113,6 +8236,10 @@ snapshots: dependencies: '@automerge/automerge-repo': 2.6.0-subduction.39 + '@inkandswitch/patchwork-providers@0.2.2(@automerge/automerge-repo@2.7.0-authors.1)': + dependencies: + '@automerge/automerge-repo': 2.7.0-authors.1 + '@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.34)': dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) @@ -8121,9 +8248,13 @@ snapshots: dependencies: '@automerge/automerge-repo': 2.6.0-subduction.39 - '@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.40)': + '@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.40(supports-color@5.5.0))': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.40 + '@automerge/automerge-repo': 2.6.0-subduction.40(supports-color@5.5.0) + + '@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.7.0-authors.1)': + dependencies: + '@automerge/automerge-repo': 2.7.0-authors.1 '@internationalized/date@3.12.2': dependencies: @@ -10151,7 +10282,7 @@ snapshots: '@noble/hashes': 1.8.0 bs58: 6.0.0 - cabbages@0.2.8: + cabbages@0.2.8(supports-color@5.5.0): dependencies: debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: @@ -11082,16 +11213,16 @@ snapshots: dependencies: '@automerge/automerge-repo': 2.5.6(supports-color@5.5.0) '@solid-primitives/utils': 6.4.1(solid-js@1.9.13) - cabbages: 0.2.8 + cabbages: 0.2.8(supports-color@5.5.0) solid-js: 1.9.13 transitivePeerDependencies: - supports-color - solid-automerge@2.0.0(@automerge/automerge-repo@2.6.0-subduction.34)(solid-js@1.9.13): + solid-automerge@2.0.0(@automerge/automerge-repo@2.6.0-subduction.34(supports-color@5.5.0))(solid-js@1.9.13)(supports-color@5.5.0): dependencies: '@automerge/automerge-repo': 2.6.0-subduction.34(supports-color@5.5.0) '@solid-primitives/utils': 6.4.1(solid-js@1.9.13) - cabbages: 0.2.8 + cabbages: 0.2.8(supports-color@5.5.0) solid-js: 1.9.13 transitivePeerDependencies: - supports-color @@ -11100,7 +11231,7 @@ snapshots: dependencies: '@automerge/automerge-repo': 2.6.0-subduction.39 '@solid-primitives/utils': 6.4.1(solid-js@1.9.13) - cabbages: 0.2.8 + cabbages: 0.2.8(supports-color@5.5.0) solid-js: 1.9.13 transitivePeerDependencies: - supports-color From 9311596040853a5f25d34f27c94162c9c769b951 Mon Sep 17 00:00:00 2001 From: grjte Date: Wed, 8 Jul 2026 12:19:59 +0100 Subject: [PATCH 2/4] refactor: clean up access-control and repo bridges --- isolation/src/bridges/access-control.ts | 53 ++++------- isolation/src/bridges/repo-bridge.ts | 117 ++++++++++++------------ 2 files changed, 75 insertions(+), 95 deletions(-) diff --git a/isolation/src/bridges/access-control.ts b/isolation/src/bridges/access-control.ts index 0274cf2b..42cacc70 100644 --- a/isolation/src/bridges/access-control.ts +++ b/isolation/src/bridges/access-control.ts @@ -38,7 +38,7 @@ import { log } from "../log.js"; /** * Scan a single document's content for automerge URLs and add any new ones to * the allowlist (unless they are denylisted or turn out to be sensitive — see - * {@link checkAndDenylistIfSensitive}). + * {@link denylistIfSensitive}). * * This is a one-shot scan, not a live subscription: it reads the document's * current contents once. Callers re-invoke it (via the wrappers below) when @@ -114,56 +114,39 @@ export async function buildAllowlist( log(`allowlisted root ${url}`); } - await populateAllowlistFromRoots( - repo, - rootUrls, - allowlist, - denylist, - isStale - ); + await refreshAllowlistFromRoots(repo, rootUrls, allowlist, denylist, isStale); return allowlist; } /** - * Scan multiple root documents into the allowlist, adding everything they - * transitively reference. Stops early if `isStale` flips (a newer init epoch - * started). Used by `buildAllowlist` for the initial boot-time seed. + * Scan all root documents into the allowlist, adding everything they + * transitively reference. Two uses: + * - boot-time seed (via `buildAllowlist`), passing `isStale` so the scan stops + * early if a newer init epoch started; + * - lazy refresh (e.g. when an access request arrives), called with no + * `isStale` to pick up references the user just added. + * + * When `isStale` is omitted the early-out is simply skipped. */ -async function populateAllowlistFromRoots( +async function refreshAllowlistFromRoots( repo: Repo, rootUrls: AutomergeUrl[], allowlist: SyncAllowlist, denylist: SyncDenylist | undefined, - isStale: () => boolean + isStale?: () => boolean ): Promise { for (const url of rootUrls) { await scanDocIntoAllowlist(repo, url, allowlist, denylist, isStale); - if (isStale()) return; - } -} - -/** - * Re-scan all root documents and add any newly-referenced automerge URLs to - * the allowlist. Called lazily (e.g. when an access request arrives) rather - * than on every change, to catch references the user just added. - */ -async function refreshAllowlistFromRoots( - repo: Repo, - rootUrls: AutomergeUrl[], - allowlist: SyncAllowlist, - denylist: SyncDenylist | undefined -): Promise { - for (const url of rootUrls) { - await scanDocIntoAllowlist(repo, url, allowlist, denylist); + if (isStale?.()) return; } } /** * Prompt the user to grant the iframe access to a document that isn't on the * allowlist and wasn't auto-allowlisted (i.e. not solely authored by the iframe; - * the author check lives in the intermediary's `document` watcher — see - * repo-bridge). Reached as the fallback for docs the iframe references but did - * not itself create. + * the author check lives in the intermediary's `resolveAccess` / + * `isAuthoredSolelyByIframe` — see repo-bridge). Reached as the fallback for docs + * the iframe references but did not itself create. * * If the document is one the host repo already knows about, the allowlist is * first refreshed (re-scanning roots for newly-added references) and the prompt @@ -366,7 +349,7 @@ async function denylistModuleSettings( * discovered later (e.g. referenced deep in user content) are caught lazily by * `denylistIfSensitive`, which shares the same recognition logic. */ -export async function populateDenylist( +async function populateDenylist( repo: Repo, denylist: SyncDenylist ): Promise { @@ -428,7 +411,7 @@ function sameDoc(a: AutomergeUrl, b: AutomergeUrl): boolean { * deliberately do NOT fingerprint folders by shape, which would wrongly block * the user's own content folders. */ -export async function denylistIfSensitive( +async function denylistIfSensitive( repo: Repo, url: AutomergeUrl, denylist: SyncDenylist diff --git a/isolation/src/bridges/repo-bridge.ts b/isolation/src/bridges/repo-bridge.ts index 906a2195..34ee37d5 100644 --- a/isolation/src/bridges/repo-bridge.ts +++ b/isolation/src/bridges/repo-bridge.ts @@ -103,9 +103,11 @@ export interface IntermediaryRepoOptions { /** Optional denylist — denylisted documents are blocked regardless of allowlist. */ denylist?: SyncDenylist; /** - * The iframe repo's Automerge author id (generated host-side). A document - * arriving from the iframe whose changes are *all* authored by this id was - * created by the iframe itself and is auto-allowlisted (no prompt). + * The iframe repo's Automerge author id (generated host-side). An unallowlisted + * document the iframe references that is resident in the intermediary (it + * arrived over the open iframe channel) and whose changes are *all* authored by + * this id was created by the iframe itself, so it is auto-allowlisted (no + * prompt). See `resolveAccess`. */ iframeAuthorId: string; /** @@ -124,6 +126,36 @@ export interface IntermediaryRepo { shutdown(): void; } +/** The state reported by a `repo.findWithProgress(...)` query. */ +type QueryState = ReturnType["peek"]>; + +/** + * Resolve to the first terminal (`ready` | `unavailable` | `failed`) state of a + * `findWithProgress` query, acting exactly once. Peeks the current state first + * (it may already be terminal), otherwise awaits the first terminal transition. + * The `settled` guard protects against a synchronous subscribe callback firing + * before `unsubscribe` is assigned. + */ +function firstTerminalState( + progress: ReturnType +): Promise { + return new Promise((resolve) => { + let settled = false; + const isTerminal = (s: QueryState) => + s.state === "ready" || s.state === "unavailable" || s.state === "failed"; + const tryResolve = (s: QueryState): boolean => { + if (settled || !isTerminal(s)) return false; + settled = true; + resolve(s); + return true; + }; + if (tryResolve(progress.peek())) return; + const unsubscribe = progress.subscribe((s) => { + if (tryResolve(s)) unsubscribe(); + }); + }); +} + /** * Create an intermediary repo gated by the caller-supplied allowlist and * denylist (both already seeded before this runs). @@ -168,17 +200,6 @@ export function createIntermediaryRepo( useWeakRef: true, }); - // The one denylist gate, shared by `announce` and `access` so the two can - // never drift apart (both must agree for the security invariant to hold). - // Returns why a document is blocked from crossing to the iframe, or null if - // the denylist permits it (the caller still applies the allowlist). - // - "not-ready": the denylist hasn't finished populating; fail closed to the - // iframe so a protected doc can't sync during the population window. - // Defense in depth — the boot path already awaits readiness before this - // repo exists, so on the normal path this never fires. - // - "denylisted": the document is in the protected set. - // The host peer is never gated here; only iframe-bound sync is. - // // Whether a document may cross the HOST channel: it must NOT be denylisted and // MUST be allowlisted. This gate is a *fast, synchronous-return policy check* // over in-memory sets — it never awaits a document fetch or a user prompt. @@ -270,16 +291,6 @@ export function createIntermediaryRepo( // Decide an undecided document without blocking the gate, then re-run the gate // via shareConfigChanged(). - // - // Classification is deterministic and event-driven (no timeout): because the - // gate currently denies the host peer for this document, the host source - // cannot supply it during this window. So we watch the intermediary's own - // query for this document — - // - it reaches `ready` ⇒ the content came from the IFRAME (the open channel) - // ⇒ the iframe created it ⇒ auto-allowlist iff every change is iframe- - // authored (else fall through to the prompt); - // - it settles `unavailable` ⇒ neither the iframe nor the (gated) host has it - // ⇒ it is a FOREIGN document the iframe is requesting ⇒ prompt the user. const resolveAccess = async (documentId: DocumentId): Promise => { const finish = (allow: boolean, reason: string) => { if (allow) { @@ -312,43 +323,29 @@ export function createIntermediaryRepo( finish(approved, approved ? "user approved" : "user denied"); }; - const progress = repo.findWithProgress(documentId); - - // Act exactly once, on the first terminal state. `settled` guards against - // both a synchronous subscribe callback (before `unsubscribe` is assigned) - // and any later transition after we've already decided. - let settled = false; - const decide = (state: ReturnType): boolean => { - if (settled) return true; - if (state.state === "ready") { - settled = true; - if (isAuthoredSolelyByIframe(state.handle)) { - finish(true, "authored by iframe"); - } else { - // Resident but not iframe-authored — treat as foreign and prompt. - void prompt(); - } - return true; - } - if (state.state === "unavailable") { - settled = true; - void prompt(); - return true; - } - if (state.state === "failed") { - settled = true; - finish(false, `query failed: ${state.error}`); - return true; + // Classification is deterministic and event-driven (no timeout): because the + // gate currently denies the host peer for this document, the host source + // cannot supply it during this window. So we watch the intermediary's own + // query for this document until it settles — + // - `ready` ⇒ the content came from the IFRAME (the open channel) ⇒ the + // iframe created it ⇒ auto-allowlist iff every change is iframe-authored + // (else it is resident but foreign-authored → fall through to the prompt); + // - `unavailable` ⇒ neither the iframe nor the (gated) host has it ⇒ it is a + // FOREIGN document the iframe is requesting ⇒ prompt the user. + const state = await firstTerminalState(repo.findWithProgress(documentId)); + if (state.state === "ready") { + if (isAuthoredSolelyByIframe(state.handle)) { + finish(true, "authored by iframe"); + } else { + await prompt(); } - return false; // still loading - }; - - // Act on the current state if already terminal, otherwise on the first - // terminal transition. - if (decide(progress.peek())) return; - const unsubscribe = progress.subscribe((state) => { - if (decide(state)) unsubscribe(); - }); + } else if (state.state === "unavailable") { + await prompt(); + } else if (state.state === "failed") { + finish(false, `query failed: ${state.error}`); + } + // `loading` is unreachable — firstTerminalState only resolves on a terminal + // state — so there is no final branch. }; return { From 23e206414d8adb58ce7a46a9208b3a591ac772d5 Mon Sep 17 00:00:00 2001 From: grjte Date: Wed, 8 Jul 2026 12:34:42 +0100 Subject: [PATCH 3/4] fix (iso): doc sync after user approval --- isolation/src/bridges/repo-bridge.ts | 200 ++++++++++++++++++--------- 1 file changed, 132 insertions(+), 68 deletions(-) diff --git a/isolation/src/bridges/repo-bridge.ts b/isolation/src/bridges/repo-bridge.ts index 34ee37d5..7cf40c94 100644 --- a/isolation/src/bridges/repo-bridge.ts +++ b/isolation/src/bridges/repo-bridge.ts @@ -130,28 +130,47 @@ export interface IntermediaryRepo { type QueryState = ReturnType["peek"]>; /** - * Resolve to the first terminal (`ready` | `unavailable` | `failed`) state of a - * `findWithProgress` query, acting exactly once. Peeks the current state first - * (it may already be terminal), otherwise awaits the first terminal transition. - * The `settled` guard protects against a synchronous subscribe callback firing - * before `unsubscribe` is assigned. + * How long the access classifier waits for the iframe to supply a document's + * content before concluding it is foreign and prompting the user. Only a ceiling + * for the *negative* (foreign) case — an iframe-created doc reaches `ready` as + * soon as its already-queued sync message applies, resolving well before this. + * Erring generous only costs a slightly later prompt for a genuinely-foreign doc + * (which then waits on `window.confirm` anyway); the trade is a rare spurious + * prompt for an iframe-created doc whose content lands after the grace. */ -function firstTerminalState( - progress: ReturnType -): Promise { +const IFRAME_SUPPLY_GRACE_MS = 500; + +/** + * Resolve `true` if the query reaches `ready` within `graceMs`, else `false`. + * + * Used to classify an undecided host-channel document while the host peer is + * held `loading` (see `hostChannelAllows`). With the host gated, the only source + * that can bring the intermediary's query to `ready` is the IFRAME supplying the + * content over the open channel — i.e. a document the iframe created. A foreign + * document the iframe merely *requested* has no supplier, so its query stays + * `pending` and this resolves `false` at the grace ceiling. The `ready` signal + * itself is deterministic and usually near-instant; the timer only bounds the + * no-signal (foreign) case. The `settled` guard makes it act exactly once. + */ +function waitForIframeSupplied( + progress: ReturnType, + graceMs: number +): Promise { return new Promise((resolve) => { let settled = false; - const isTerminal = (s: QueryState) => - s.state === "ready" || s.state === "unavailable" || s.state === "failed"; - const tryResolve = (s: QueryState): boolean => { - if (settled || !isTerminal(s)) return false; + let timer: ReturnType | undefined; + let unsubscribe: (() => void) | undefined; + const finish = (v: boolean) => { + if (settled) return; settled = true; - resolve(s); - return true; + if (timer !== undefined) clearTimeout(timer); + unsubscribe?.(); + resolve(v); }; - if (tryResolve(progress.peek())) return; - const unsubscribe = progress.subscribe((s) => { - if (tryResolve(s)) unsubscribe(); + if (progress.peek().state === "ready") return finish(true); + timer = setTimeout(() => finish(false), graceMs); + unsubscribe = progress.subscribe((s) => { + if (s.state === "ready") finish(true); }); }); } @@ -201,38 +220,47 @@ export function createIntermediaryRepo( }); // Whether a document may cross the HOST channel: it must NOT be denylisted and - // MUST be allowlisted. This gate is a *fast, synchronous-return policy check* - // over in-memory sets — it never awaits a document fetch or a user prompt. - // automerge-repo holds a peer at sharePolicyState "loading" for as long as - // `access` is unresolved, which would stall the sync (no doc-unavailable, no - // content) — so any slow or interactive decision must happen out of band. An - // undecided document is DENIED now; `resolveAccess` decides it in the - // background and, once decided, calls `repo.shareConfigChanged()` to re-run - // this gate and re-engage the waiting iframe peer (see the automerge team's - // "immediately deny, prompt, then fire shareConfigChanged" guidance). + // MUST be allowlisted. Terminal answers (denylisted / allowlisted / user- + // denied) return synchronously. An *undecided* document returns a PENDING + // promise that resolves once `resolveAccess` decides it. + // + // Why pending rather than an immediate deny: automerge-repo holds a peer at + // sharePolicyState "loading" while its `access` promise is unresolved, and a + // query with any "loading" peer stays `pending` (never `unavailable`). If we + // denied the host peer up front instead, the intermediary's query would settle + // `unavailable` and emit `doc-unavailable` to the iframe — which makes the + // iframe's one-shot `find()` reject, so the tool gives up and never recovers + // even after the user approves (it would need a full re-render). Holding the + // host peer "loading" keeps the iframe's `find()` pending until we decide, so + // an approval resolves it to `ready` with no re-render. See `resolveAccess`. // // While the denylist is still populating we fail closed (allow nothing to/from // the host) — the boot path awaits denylist readiness before this repo exists, // so this is defense in depth. This is the single security boundary; the // iframe channel is open. - const hostChannelAllows = (documentId: DocumentId): boolean => { + const hostChannelAllows = ( + documentId: DocumentId + ): boolean | Promise => { if (denylist && !denylist.isReady) return false; if (denylist?.has(documentId)) return false; - if (allowlist.has(documentId)) return true; - // Already decided-no, or a decision is already in flight: deny without - // re-prompting / re-resolving (this gate is called repeatedly per re-eval). - if (denied.has(documentId)) return false; - if (pending.has(documentId)) return false; - - // Undecided: kick off out-of-band resolution and deny for now. - pending.add(documentId); - void resolveAccess(documentId); - return false; + if (allowlist.has(documentId)) return true; // approved / auto-allowed + if (denied.has(documentId)) return false; // user said no + + // Undecided: return the SAME pending decision promise on every re-eval + // (dedup), and kick off out-of-band resolution exactly once. + const decision = decisionFor(documentId); + if (!pending.has(documentId)) { + pending.add(documentId); + void resolveAccess(documentId); + } + return decision.promise; }; // Per-channel gate. The iframe channel is fully open (all docs sync both // ways); the host channel enforces denylist-then-allowlist. Both `access` - // (bidirectional gate) and `announce` (proactive push) use the same rule. + // (bidirectional gate) and `announce` (proactive push) use the same rule — so + // once a pending decision resolves true, the host peer becomes "announce" and + // the intermediary actively serves the doc in both directions. const shareGate = async ( peerId: PeerId, documentId?: DocumentId @@ -262,13 +290,46 @@ export function createIntermediaryRepo( hostRepo.networkSubsystem.addNetworkAdapter(isolationHostAdapter); // ── Out-of-band access resolution ──────────────────────────── - // The gate (hostChannelAllows) denies any undecided document immediately and - // enqueues it here. `pending` dedupes in-flight resolutions (the gate is - // called repeatedly as the sync layer re-evaluates); `denied` remembers a - // user's "no" so we never re-prompt for the same document. + // The gate (hostChannelAllows) returns a pending decision promise for any + // undecided document and enqueues it here. + // - `pending` dedupes: it guards that we spawn exactly one `resolveAccess` + // (and thus at most one prompt) per document, even though the gate is + // re-evaluated repeatedly. + // - `denied` remembers a user's "no" so the gate answers a synchronous false + // without re-prompting. + // - `decisions` holds the single shared pending promise per document, so + // every re-evaluation during the window returns the *same* promise (which + // is what pins the host peer at "loading" — see hostChannelAllows). const pending = new Set(); const denied = new Set(); + type Decision = { + promise: Promise; + resolve: (allow: boolean) => void; + settled: boolean; + }; + const decisions = new Map(); + + const decisionFor = (documentId: DocumentId): Decision => { + let d = decisions.get(documentId); + if (!d) { + let resolve!: (allow: boolean) => void; + const promise = new Promise((res) => { + resolve = res; + }); + d = { promise, resolve, settled: false }; + decisions.set(documentId, d); + } + return d; + }; + + const settleDecision = (documentId: DocumentId, allow: boolean): void => { + const d = decisionFor(documentId); + if (d.settled) return; + d.settled = true; + d.resolve(allow); + }; + // Whether every change in a *resident* document was authored by the iframe. // Reads the already-ready handle directly — it must never trigger a fetch (a // fetch would re-enter the host-channel gate and deadlock), so the caller is @@ -289,8 +350,8 @@ export function createIntermediaryRepo( } }; - // Decide an undecided document without blocking the gate, then re-run the gate - // via shareConfigChanged(). + // Decide an undecided document out of band (never blocking the gate), resolve + // its pending decision promise, then re-run the gate via shareConfigChanged(). const resolveAccess = async (documentId: DocumentId): Promise => { const finish = (allow: boolean, reason: string) => { if (allow) { @@ -301,9 +362,14 @@ export function createIntermediaryRepo( log(`access ${documentId} denied (${reason})`); } pending.delete(documentId); - // Re-run the (now-decided) gate for every doc and re-engage the iframe - // peer, which was marked hasRequested when the first denial sent it - // doc-unavailable. + // Resolve the pending host-gate promise FIRST (unpins the host peer from + // "loading"), then re-run the gate for every doc. The fresh evaluation + // triggered by shareConfigChanged now sees the doc in allowlist/denied and + // answers a synchronous boolean — flipping the host peer to "announce" (so + // the intermediary fetches the doc from the host and pushes it to the + // still-waiting iframe) or to "denied" (query settles unavailable → the + // iframe is told, as it should be on an explicit denial). + settleDecision(documentId, allow); repo.shareConfigChanged(); }; @@ -323,29 +389,27 @@ export function createIntermediaryRepo( finish(approved, approved ? "user approved" : "user denied"); }; - // Classification is deterministic and event-driven (no timeout): because the - // gate currently denies the host peer for this document, the host source - // cannot supply it during this window. So we watch the intermediary's own - // query for this document until it settles — - // - `ready` ⇒ the content came from the IFRAME (the open channel) ⇒ the - // iframe created it ⇒ auto-allowlist iff every change is iframe-authored - // (else it is resident but foreign-authored → fall through to the prompt); - // - `unavailable` ⇒ neither the iframe nor the (gated) host has it ⇒ it is a - // FOREIGN document the iframe is requesting ⇒ prompt the user. - const state = await firstTerminalState(repo.findWithProgress(documentId)); - if (state.state === "ready") { - if (isAuthoredSolelyByIframe(state.handle)) { - finish(true, "authored by iframe"); - } else { - await prompt(); - } - } else if (state.state === "unavailable") { + // Classify iframe-created vs foreign. The host peer is held "loading" by the + // pending gate, so the intermediary's query for this doc can only reach + // `ready` if the IFRAME supplied the content over the open channel — i.e. the + // iframe created it. If the iframe doesn't supply it within the grace window, + // it is a FOREIGN document the iframe is merely requesting → prompt. + const progress = repo.findWithProgress(documentId); + const iframeSupplied = await waitForIframeSupplied( + progress, + IFRAME_SUPPLY_GRACE_MS + ); + const state = progress.peek(); + if ( + iframeSupplied && + state.state === "ready" && + isAuthoredSolelyByIframe(state.handle) + ) { + finish(true, "authored by iframe"); + } else { + // Not iframe-supplied, or resident but not solely iframe-authored → prompt. await prompt(); - } else if (state.state === "failed") { - finish(false, `query failed: ${state.error}`); } - // `loading` is unreachable — firstTerminalState only resolves on a terminal - // state — so there is no final branch. }; return { From 3ff85afaae56a7947c58ba2f6cbfb62da66866b9 Mon Sep 17 00:00:00 2001 From: grjte Date: Wed, 8 Jul 2026 12:50:30 +0100 Subject: [PATCH 4/4] refactor(iso): cleanup repo bridge + AC again --- isolation/src/bridges/repo-bridge.ts | 67 ++++++++++++++-------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/isolation/src/bridges/repo-bridge.ts b/isolation/src/bridges/repo-bridge.ts index 7cf40c94..75e4b58d 100644 --- a/isolation/src/bridges/repo-bridge.ts +++ b/isolation/src/bridges/repo-bridge.ts @@ -126,9 +126,6 @@ export interface IntermediaryRepo { shutdown(): void; } -/** The state reported by a `repo.findWithProgress(...)` query. */ -type QueryState = ReturnType["peek"]>; - /** * How long the access classifier waits for the iframe to supply a document's * content before concluding it is foreign and prompting the user. Only a ceiling @@ -141,36 +138,40 @@ type QueryState = ReturnType["peek"]>; const IFRAME_SUPPLY_GRACE_MS = 500; /** - * Resolve `true` if the query reaches `ready` within `graceMs`, else `false`. + * Resolve with the ready `DocHandle` if the query reaches `ready` within + * `graceMs`, else `null`. * * Used to classify an undecided host-channel document while the host peer is * held `loading` (see `hostChannelAllows`). With the host gated, the only source * that can bring the intermediary's query to `ready` is the IFRAME supplying the * content over the open channel — i.e. a document the iframe created. A foreign * document the iframe merely *requested* has no supplier, so its query stays - * `pending` and this resolves `false` at the grace ceiling. The `ready` signal + * `pending` and this resolves `null` at the grace ceiling. The `ready` signal * itself is deterministic and usually near-instant; the timer only bounds the - * no-signal (foreign) case. The `settled` guard makes it act exactly once. + * no-signal (foreign) case. Returning the handle (rather than a boolean) hands + * the author check the exact doc it observed ready. The `settled` guard makes it + * act exactly once. */ function waitForIframeSupplied( progress: ReturnType, graceMs: number -): Promise { +): Promise | null> { return new Promise((resolve) => { let settled = false; let timer: ReturnType | undefined; let unsubscribe: (() => void) | undefined; - const finish = (v: boolean) => { + const finish = (handle: DocHandle | null) => { if (settled) return; settled = true; if (timer !== undefined) clearTimeout(timer); unsubscribe?.(); - resolve(v); + resolve(handle); }; - if (progress.peek().state === "ready") return finish(true); - timer = setTimeout(() => finish(false), graceMs); + const initial = progress.peek(); + if (initial.state === "ready") return finish(initial.handle); + timer = setTimeout(() => finish(null), graceMs); unsubscribe = progress.subscribe((s) => { - if (s.state === "ready") finish(true); + if (s.state === "ready") finish(s.handle); }); }); } @@ -192,12 +193,17 @@ function waitForIframeSupplied( * it can only ever hold allowlisted docs + docs the iframe itself pushed. A * denylisted doc can never enter the intermediary to reach the iframe. * - * A document the iframe references that isn't yet allowlisted is DENIED by the - * gate immediately and resolved out of band (see `resolveAccess` below): if the - * iframe created it (it is resident and authored solely by the iframe) it is - * auto-allowlisted; otherwise the user is prompted. Once decided, the gate is - * re-run via `repo.shareConfigChanged()`. The gate itself never blocks on a - * fetch or a prompt — doing so would stall the sync (see `hostChannelAllows`). + * A document the iframe references that isn't yet allowlisted is left UNDECIDED + * by the gate, which returns a pending promise (see `hostChannelAllows`) rather + * than an immediate deny — this holds the host peer at sharePolicyState + * "loading" so the intermediary's query stays `pending` and the iframe's + * one-shot `find()` never rejects. Meanwhile it is classified out of band (see + * `resolveAccess` below): if the iframe created it (resident and authored solely + * by the iframe) it is auto-allowlisted; otherwise the user is prompted. Once + * decided, the pending promise is resolved and the gate re-run via + * `repo.shareConfigChanged()`, which now answers synchronously from the + * allowlist/denied sets. The gate itself never blocks on a fetch or a prompt — + * doing so would stall the sync (see `hostChannelAllows`). */ export function createIntermediaryRepo( options: IntermediaryRepoOptions @@ -324,6 +330,9 @@ export function createIntermediaryRepo( }; const settleDecision = (documentId: DocumentId, allow: boolean): void => { + // Normally a get — the gate created this decision before spawning + // resolveAccess. The create-if-missing keeps settle safe if a future path + // ever settles a doc before any gate evaluation. const d = decisionFor(documentId); if (d.settled) return; d.settled = true; @@ -362,11 +371,11 @@ export function createIntermediaryRepo( log(`access ${documentId} denied (${reason})`); } pending.delete(documentId); - // Resolve the pending host-gate promise FIRST (unpins the host peer from - // "loading"), then re-run the gate for every doc. The fresh evaluation - // triggered by shareConfigChanged now sees the doc in allowlist/denied and - // answers a synchronous boolean — flipping the host peer to "announce" (so - // the intermediary fetches the doc from the host and pushes it to the + // Resolve the pending host-gate promise FIRST (see `hostChannelAllows`), + // then re-run the gate for every doc. The fresh evaluation triggered by + // shareConfigChanged now sees the doc in allowlist/denied and answers a + // synchronous boolean — flipping the host peer to "announce" (so the + // intermediary fetches the doc from the host and pushes it to the // still-waiting iframe) or to "denied" (query settles unavailable → the // iframe is told, as it should be on an explicit denial). settleDecision(documentId, allow); @@ -395,16 +404,8 @@ export function createIntermediaryRepo( // iframe created it. If the iframe doesn't supply it within the grace window, // it is a FOREIGN document the iframe is merely requesting → prompt. const progress = repo.findWithProgress(documentId); - const iframeSupplied = await waitForIframeSupplied( - progress, - IFRAME_SUPPLY_GRACE_MS - ); - const state = progress.peek(); - if ( - iframeSupplied && - state.state === "ready" && - isAuthoredSolelyByIframe(state.handle) - ) { + const handle = await waitForIframeSupplied(progress, IFRAME_SUPPLY_GRACE_MS); + if (handle && isAuthoredSolelyByIframe(handle)) { finish(true, "authored by iframe"); } else { // Not iframe-supplied, or resident but not solely iframe-authored → prompt.