From 0dde070ab9b5a087cda62fc77764668263c21666 Mon Sep 17 00:00:00 2001 From: chee Date: Tue, 27 Oct 2026 05:38:07 +0000 Subject: [PATCH 1/2] add conformsTo to datatypes like uttype --- .changeset/datatype-conforms-to.md | 10 +++ core/elements/src/legacy-impl.ts | 41 +++++++-- core/elements/test/patchwork-view.test.ts | 65 +++++++++++++- core/plugins/src/datatypes.ts | 29 ++++++ core/plugins/src/tools.ts | 102 +++++++++------------- package-load-gate.md | 55 ++++++++++++ 6 files changed, 232 insertions(+), 70 deletions(-) create mode 100644 .changeset/datatype-conforms-to.md create mode 100644 package-load-gate.md diff --git a/.changeset/datatype-conforms-to.md b/.changeset/datatype-conforms-to.md new file mode 100644 index 000000000..e84d9bbbb --- /dev/null +++ b/.changeset/datatype-conforms-to.md @@ -0,0 +1,10 @@ +--- +"@inkandswitch/patchwork-plugins": patch +"@inkandswitch/patchwork-elements": patch +--- + +Datatypes can declare `conformsTo: string[]`, listing the datatype ids they are also a kind of, like a UTI's `UTTypeConformsTo`. A `sketchbox` datatype with `conformsTo: ["folder", "tldraw"]` opens in folder and tldraw tools. + +`getSupportedToolsForType` and `getSupportedTools` now include tools that support any supertype. `getFallbackTool` ranks by how close the match is: a tool naming the doc's own type, then its nearest supertype, then a `*` wildcard, with ties going by id. The new `getSupertypes(type)` returns every registered datatype `type` conforms to, transitively and nearest first, not including `type` itself. + +`` treats a supertype match as a real tool rather than a wildcard stopgap. When a datatype registers with a `conformsTo` that makes an existing tool a closer fit, it swaps that tool in. diff --git a/core/elements/src/legacy-impl.ts b/core/elements/src/legacy-impl.ts index a8f7030cd..fd48122f7 100644 --- a/core/elements/src/legacy-impl.ts +++ b/core/elements/src/legacy-impl.ts @@ -14,9 +14,11 @@ import { import { getFallbackTool, getRegistry, + getSupertypes, isKeyhiveDoc, isLoadablePlugin, registerPlugins, + type DatatypeDescription, type LoadedTool, type ToolDescription, type ToolElement, @@ -29,8 +31,9 @@ const log = debug("patchwork:elements:legacy"); /** * Whether `tool` supports `type` only through a `*` wildcard rather than by - * naming the datatype explicitly. A wildcard-only match means the tool is a - * generic fallback (e.g. a raw viewer), not a tool built for this datatype. + * naming the datatype or one of its supertypes. A wildcard-only match means the + * tool is a generic fallback (e.g. a raw viewer), not a tool built for this + * datatype. */ function isWildcardOnlyMatch( tool: LoadedTool, @@ -38,7 +41,9 @@ function isWildcardOnlyMatch( ): boolean { const datatypes = tool.supportedDatatypes; const list = datatypes === "*" ? ["*"] : datatypes; - return list.includes("*") && (type === undefined || !list.includes(type)); + if (!list.includes("*")) return false; + if (type === undefined) return true; + return ![type, ...getSupertypes(type)].some((t) => list.includes(t)); } const State = { @@ -415,9 +420,32 @@ export class LegacyImpl { } ); + // Registering the doc's datatype, or one of its supertypes, can bring in a + // `conformsTo` that makes an already-registered tool a closer fit. + const removeDatatypeListener = getRegistry( + "patchwork:datatype" + ).on("registered", (added) => { + if (!this.#handle || this.#toolId) return; + const type = getType(this.#handle.doc()); + if (!type) return; + if (added.id != type && !getSupertypes(type).includes(added.id)) return; + const fallback = getFallbackTool(this.#handle.doc()); + if (!fallback || fallback.id == this.#fallbackId) return; + this.#fallbackId = fallback.id; + if (!fallback.module) { + // The "loaded" listener mounts it once it's in. + if (isLoadablePlugin(fallback)) toolRegistry.load(fallback.id); + } else if (this.#state == State.unable) { + this.#queueRender(); + } else if (this.#state == State.fallback) { + void this.#teardown().then(() => this.#init()); + } + }); + this.#teardowns.add(() => { removeAddedListener(); removeLoadedListener(); + removeDatatypeListener(); }); const repo = this.#repo; @@ -451,15 +479,16 @@ export class LegacyImpl { }; // A registration can shift the fallback only when we don't have one yet or - // the new tool supports the doc's type (a specific match sorts ahead of a - // wildcard, so a supporting tool can displace the current pick). + // the new tool supports the doc's type or a supertype (a closer match sorts + // ahead, so a supporting tool can displace the current pick). #couldChangeFallback(added: ToolDescription): boolean { if (!this.#fallbackId) return true; const supported = added.supportedDatatypes; if (!supported) return false; if (supported === "*" || supported.includes("*")) return true; const type = this.#handle ? getType(this.#handle.doc()) : undefined; - return type != null && supported.includes(type); + if (type == null) return false; + return [type, ...getSupertypes(type)].some((t) => supported.includes(t)); } // Serialized: overlapping calls (doc-url and tool-id both changing in one diff --git a/core/elements/test/patchwork-view.test.ts b/core/elements/test/patchwork-view.test.ts index 6c246608c..8ee5bede9 100644 --- a/core/elements/test/patchwork-view.test.ts +++ b/core/elements/test/patchwork-view.test.ts @@ -10,6 +10,7 @@ import { const registry = getRegistry("patchwork:component"); const toolRegistry = getRegistry("patchwork:tool"); +const datatypeRegistry = getRegistry("patchwork:datatype"); type Counters = { mounts: number; cleanups: number }; @@ -102,9 +103,13 @@ describe("patchwork-view (component mode)", () => { // tests take it in turns: `afterEach` unregisters, or the previous test's // wildcard would be the one a later fallback picks. const registeredTools: string[] = []; +const registeredDatatypes: string[] = []; function registerWildcardTool(): Counters { - const id = `test-wildcard-${++seq}`; + return registerTool(`test-wildcard-${++seq}`, "*"); +} + +function registerTool(id: string, supportedDatatypes: "*" | string[]): Counters { const counters: Counters = { mounts: 0, cleanups: 0 }; const render = () => { counters.mounts++; @@ -117,7 +122,7 @@ function registerWildcardTool(): Counters { id, type: "patchwork:tool", name: id, - supportedDatatypes: "*", + supportedDatatypes, load: async () => render, module: render, } as never, @@ -127,6 +132,20 @@ function registerWildcardTool(): Counters { return counters; } +function registerDatatype(id: string, conformsTo?: string[]) { + datatypeRegistry.register( + { + id, + type: "patchwork:datatype", + name: id, + icon: "", + conformsTo, + } as never, + `test://${id}` + ); + registeredDatatypes.push(id); +} + describe("patchwork-view (legacy mode)", () => { const repo = new Repo({}); @@ -141,6 +160,8 @@ describe("patchwork-view (legacy mode)", () => { afterEach(() => { for (const id of registeredTools) toolRegistry.remove(id); registeredTools.length = 0; + for (const id of registeredDatatypes) datatypeRegistry.remove(id); + registeredDatatypes.length = 0; }); function mount(doc: unknown) { @@ -178,4 +199,44 @@ describe("patchwork-view (legacy mode)", () => { expect(raw.mounts).toBe(1); }); + + it("opens a doc in a tool for its supertype ahead of the wildcard", async () => { + const raw = registerWildcardTool(); + const folder = registerTool(`test-folder-tool-${++seq}`, ["test-folder"]); + registerDatatype("test-sketchbox", ["test-folder", "test-tldraw"]); + + mount({ "@patchwork": { type: "test-sketchbox" } }); + await settle(); + + expect(folder.mounts).toBe(1); + expect(raw.mounts).toBe(0); + }); + + it("prefers the nearest supertype's tool", async () => { + const far = registerTool(`test-a-far-${++seq}`, ["test-file"]); + const near = registerTool(`test-z-near-${++seq}`, ["test-text"]); + registerDatatype("test-javascript", ["test-text"]); + registerDatatype("test-text", ["test-file"]); + + mount({ "@patchwork": { type: "test-javascript" } }); + await settle(); + + expect(near.mounts).toBe(1); + expect(far.mounts).toBe(0); + }); + + it("swaps the wildcard for a supertype's tool when the datatype registers", async () => { + const raw = registerWildcardTool(); + const folder = registerTool(`test-folder-tool-${++seq}`, ["test-folder"]); + + mount({ "@patchwork": { type: "test-late-sketchbox" } }); + await settle(); + expect(raw.mounts).toBe(1); + + registerDatatype("test-late-sketchbox", ["test-folder"]); + await settle(); + + expect(raw.cleanups).toBe(1); + expect(folder.mounts).toBe(1); + }); }); diff --git a/core/plugins/src/datatypes.ts b/core/plugins/src/datatypes.ts index 7f906c77b..98c0ecc97 100644 --- a/core/plugins/src/datatypes.ts +++ b/core/plugins/src/datatypes.ts @@ -10,6 +10,7 @@ import type { LoadedPlugin, PluginDescription, } from "./registry/types.js"; +import { getRegistry } from "./registry/index.js"; import type { HasPatchworkMetadata } from "@inkandswitch/patchwork-filesystem"; import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive"; import { isKeyhiveDoc } from "./keyhive.js"; @@ -26,6 +27,34 @@ export interface DatatypeDescription extends PluginDescription { type: "patchwork:datatype"; icon: string; unlisted?: boolean; + /** + * The datatype ids this datatype is also a kind of, like a UTI's + * `UTTypeConformsTo`. A doc of this type opens in tools that support any of + * them (transitively). List only the direct parents. Conforming is a promise + * that the doc also has each parent's shape, so the parents' tools can work + * on it. + */ + conformsTo?: string[]; +} + +/** + * Every datatype `type` conforms to through `conformsTo`, transitively and + * nearest first, not including `type` itself. Only registered datatypes + * contribute, so the result can grow as packages register. + */ +export function getSupertypes(type: string): string[] { + const registry = getRegistry("patchwork:datatype"); + // Breadth-first, so nearer supertypes come first; `seen` also stops cycles. + const queue = [type]; + const seen = new Set(queue); + for (let i = 0; i < queue.length; i++) { + for (const parent of registry.get(queue[i])?.conformsTo ?? []) { + if (seen.has(parent)) continue; + seen.add(parent); + queue.push(parent); + } + } + return queue.slice(1); } // Loadable Datatype description using the generic LoadablePlugin diff --git a/core/plugins/src/tools.ts b/core/plugins/src/tools.ts index 12e4dd672..917756e15 100644 --- a/core/plugins/src/tools.ts +++ b/core/plugins/src/tools.ts @@ -13,6 +13,7 @@ import { type HasPatchworkMetadata, } from "@inkandswitch/patchwork-filesystem"; import { getRegistry } from "./registry/index.js"; +import { getSupertypes } from "./datatypes.js"; import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive"; @@ -53,14 +54,34 @@ export type Tool = LoadablePlugin< export type LegacyEditorProps = { docUrl: AutomergeUrl }; +function datatypesOf(tool: ToolDescription): string[] { + const datatypes = tool.supportedDatatypes; + if (!datatypes) return []; + return datatypes === "*" ? ["*"] : datatypes; +} + +/** + * How well `tool` fits a doc whose type and supertypes are `types` (as + * `[type, ...getSupertypes(type)]`): the index of the nearest one it names, + * `types.length` if it only matches through a `*` wildcard, or `undefined` if + * it doesn't support the doc at all. Lower is a closer fit. + */ +function matchRank(tool: ToolDescription, types: string[]): number | undefined { + const datatypes = datatypesOf(tool); + const index = types.findIndex((type) => datatypes.includes(type)); + if (index >= 0) return index; + if (datatypes.includes("*")) return types.length; + return undefined; +} + +/** + * Tools that support `type`: those naming it or one of its supertypes (see + * {@link getSupertypes}), and wildcard tools. + */ export function getSupportedToolsForType(type: string): LoadedTool[] { + const types = [type, ...getSupertypes(type)]; const plugins = getRegistry("patchwork:tool").filter( - (desc) => { - return ( - desc.supportedDatatypes?.includes(type) || - desc.supportedDatatypes?.includes("*") - ); - } + (desc) => matchRank(desc, types) !== undefined ); return plugins as LoadedTool[]; @@ -72,61 +93,18 @@ export function getSupportedTools(doc: HasPatchworkMetadata): LoadedTool[] { return getSupportedToolsForType(type); } +/** + * The listed tool that fits `doc` most closely: one naming its type, then one + * naming its nearest supertype, then a wildcard. Ties go by id. + */ export function getFallbackTool(doc: HasPatchworkMetadata) { - const type = getType(doc)!; - const plugins = getSupportedTools(doc); - return sortPlugins( - plugins, - "supportedDatatypes", - type, - "id" - )?.filter((tool) => !tool.unlisted)?.[0]; + const type = getType(doc); + if (!type) return undefined; + const types = [type, ...getSupertypes(type)]; + return getSupportedToolsForType(type) + .filter((tool) => !tool.unlisted) + .sort( + (a, b) => + matchRank(a, types)! - matchRank(b, types)! || a.id.localeCompare(b.id) + )[0]; } - -const sortPlugins = < - T extends LoadedPlugin, - D extends PluginDescription, - I, ->( - plugins: T[], - matchField: keyof D, - matchValue: string, - sortField?: keyof D -): T[] => { - return [...plugins].sort((a, b) => { - const aValue = a[matchField]; - const bValue = b[matchField]; - - // Convert string values to arrays for consistent comparison - const aArray = Array.isArray(aValue) - ? (aValue as string[]) - : [aValue as string]; - const bArray = Array.isArray(bValue) - ? (bValue as string[]) - : [bValue as string]; - - const aHasWildcard = aArray.includes("*"); - const bHasWildcard = bArray.includes("*"); - const aHasMatch = aArray.includes(matchValue); - const bHasMatch = bArray.includes(matchValue); - - // Specific matches come first - if (aHasMatch && !bHasMatch) return -1; - if (!aHasMatch && bHasMatch) return 1; - - // Then wildcard matches come last - if (aHasWildcard && !bHasWildcard) return 1; - if (!aHasWildcard && bHasWildcard) return -1; - - // If both are wildcards or both are specific matches, sort by the optional sort field - if (sortField) { - const aSort = a[sortField]; - const bSort = b[sortField]; - if (typeof aSort === "string" && typeof bSort === "string") { - return aSort.localeCompare(bSort); - } - } - - return 0; - }); -}; diff --git a/package-load-gate.md b/package-load-gate.md new file mode 100644 index 000000000..dbc89623d --- /dev/null +++ b/package-load-gate.md @@ -0,0 +1,55 @@ +# Package load gate + +Status: planned, not implemented. + +Patchwork runs code from packages: their entry points when they're imported, and their plugins' functions after that (`init`, `getTitle`, tool and component render functions). Today nothing checks a package before it runs, except the `window.confirm` in front of a suggested import. This is the plan for checking packages before their code runs, and what that check does not cover. + +## Where user code enters + +- `importModule` in `core/filesystem/src/packages.ts` is the `import()` that everything goes through. It's called by `importPackageFromFolderDocUrl`, `importPackageFromHttpUrl` and `importPackage`. `importPluginFromFolderDocUrl` also runs a plugin's `load()` or `import(plugin.import)`. +- `PluginRegistry.load` in `core/plugins/src/registry/registry.ts` runs `description.load()` or `import(description.import)`. +- `ModuleWatcher` in `core/filesystem/src/module-watcher.ts` imports every package listed in the module settings, with no prompt, and imports it again whenever the package doc changes. +- The module-loader worker (`core/bootloader/src/module-loader-worker.ts`) imports each package's entry point to discover its plugins. The package's top-level code runs there first, and runs again on the main thread when a plugin is loaded (`core/bootloader/src/module-loader.ts`). +- `#loadSuggestedImport` / `#importSuggestedModule` in `core/elements/src/legacy-impl.ts` import a doc's `suggestedImportUrl` after a `window.confirm`. +- Plugin code is called at `createDocOfDatatype2` (`init`, `core/plugins/src/datatypes.ts`), the router (`getTitle`, `core/patchwork/src/router.ts`), tool render (`core/elements/src/legacy-impl.ts`) and component render (`core/elements/src/patchwork-view.ts`). +- Files from documents are served by the automerge SharedWorker's resolver (`core/bootloader/src/resolver.ts`), reached through the service worker's `/automerge%3A…` URLs (`core/bootloader/src/service-worker.ts`). + +## Decisions + +**Check whole packages.** The unit is a package root doc at specific heads, which is the first path segment of every module URL (`/automerge%3A%23/…`). Relative imports inside a package keep that segment, so one check covers the whole package. + +**Not in the plugin registry.** By the time `registerPlugins` is called, the entry point has already been imported, by the module-loader worker's discovery pass or by `#importSuggestedModule`. A check in the registry would only stop plugin implementations, not the package's top-level code. + +**An `onBeforeLoad` hook inside `importPackage*`.** It's set through a module-level setter in `@inkandswitch/patchwork-filesystem`, which `patchwork` calls during startup. Roughly: + +```ts +onBeforeLoad?: (pkg: { + url: string; // automerge url at heads, or http(s) url + source: "settings" | "suggested" | "plugin-import" | "package"; +}) => boolean | Promise; +``` + +`@inkandswitch/patchwork-filesystem` is an import-map external (`core/bootloader/src/externals-list.ts`), so every package shares the same `importPackage`. That makes it the approved way for one package to import another, instead of a raw relative `import("../base/packages/…")`. The `window.confirm` in front of suggested imports becomes the default hook for the `"suggested"` source. A plugin descriptor's `import` string can point outside its own package, so `registry.load` either runs the hook on that URL or requires it to stay inside the package that registered it. + +The hook can't really know who is asking. A package can pass its `import.meta.url`, but same-origin code can lie about it, so the policy should treat that as a hint and not rely on it. + +**The resolver enforces approvals.** Before calling `import()`, `importPackage` tells the resolver that this package at these heads is approved, and waits for the resolver to acknowledge it. The resolver refuses `script`, `worker` and `sharedworker` requests under any package that hasn't been approved. That catches `../` climbs into another package, which land on a different package and get checked on their own. + +- A refused request returns `abort`, which becomes a network error. The browser remembers a failed import for that URL in that realm, so the approval has to be acknowledged before `import()` runs. Otherwise a race leaves a failure memoized for a URL that was about to be approved. +- Refuse both paths: the `cache.put` path and the inline path that serves responses which aren't pinned. Refusing only at `cache.put` would still serve unapproved code, just without storing it. +- Data requests (`package.json` and the heads lookup in `fetchLatestViaServiceWorker`) stay allowed, because the hook needs them to find the heads it's being asked about. +- Don't write anything to the cache unless it passed the check. The service worker serves cache hits without asking the resolver, so being in the cache should mean it was checked. To revoke an approval, delete the cached entries under that package, or put a policy version in `cachename`. + +**Pin links when a package is published.** The heads on a URL pin only the doc it names. Links inside a folder are usually bare, so the files under a pinned root can still change, and `resolvePath` marks them `pinned: false` and they're never cached (see `Resolved.pinned` in `core/filesystem/src/resolve.ts`). Redirecting to pinned URLs doesn't fix this, because a module's identity is the URL that was requested, before the redirect, so a redirect leaves identity on the bare URL until the page reloads. Files reached by relative imports only become content-addressed if publishing writes links with heads, so that the root's heads cover everything below it. + +## Known gaps + +The gate keeps unchecked code out through the normal loading path. It does not contain code that's already running in the origin. + +- **Forging cache entries.** Any same-origin code can call `caches.open(cachename).put(...)` and write a forged response, and the service worker serves cache hits without asking the resolver. Refusing to cache unchecked responses does not prevent this. When the gate is built, add a comment at the resolver's `cache.put` and at the service worker's cache-hit path saying so. +- **Imports that bypass packages.** Without a CSP of `script-src 'self'` (no `data:`, `blob:`, `'unsafe-eval'`, `'wasm-unsafe-eval'` or remote hosts), code can still `import()` `data:`, `blob:` and remote URLs, use `new Function`, or instantiate wasm it got with `fetch`. +- **Calling the registry directly.** `registerPlugins` and `getRegistry` are importable, so a running package can register plugins without going through the hook. +- **Revoking an approval.** `unregisterPlugins(url)` removes a package's plugins from the registry, but code that has already run stays in memory until the page reloads. +- **What the module-loader worker can do.** Discovery runs package code in a same-origin worker, which can reach IndexedDB, the Cache API and `fetch`. The main thread has to run the hook before posting `discover`. + +Containing code that is already running needs a separate origin or a sandboxed iframe or worker. From 279607ed01ac32be38943c335d9c0cc408b7879e Mon Sep 17 00:00:00 2001 From: chee Date: Tue, 27 Oct 2026 05:40:52 +0000 Subject: [PATCH 2/2] Delete package-load-gate.md accidental commit --- package-load-gate.md | 55 -------------------------------------------- 1 file changed, 55 deletions(-) delete mode 100644 package-load-gate.md diff --git a/package-load-gate.md b/package-load-gate.md deleted file mode 100644 index dbc89623d..000000000 --- a/package-load-gate.md +++ /dev/null @@ -1,55 +0,0 @@ -# Package load gate - -Status: planned, not implemented. - -Patchwork runs code from packages: their entry points when they're imported, and their plugins' functions after that (`init`, `getTitle`, tool and component render functions). Today nothing checks a package before it runs, except the `window.confirm` in front of a suggested import. This is the plan for checking packages before their code runs, and what that check does not cover. - -## Where user code enters - -- `importModule` in `core/filesystem/src/packages.ts` is the `import()` that everything goes through. It's called by `importPackageFromFolderDocUrl`, `importPackageFromHttpUrl` and `importPackage`. `importPluginFromFolderDocUrl` also runs a plugin's `load()` or `import(plugin.import)`. -- `PluginRegistry.load` in `core/plugins/src/registry/registry.ts` runs `description.load()` or `import(description.import)`. -- `ModuleWatcher` in `core/filesystem/src/module-watcher.ts` imports every package listed in the module settings, with no prompt, and imports it again whenever the package doc changes. -- The module-loader worker (`core/bootloader/src/module-loader-worker.ts`) imports each package's entry point to discover its plugins. The package's top-level code runs there first, and runs again on the main thread when a plugin is loaded (`core/bootloader/src/module-loader.ts`). -- `#loadSuggestedImport` / `#importSuggestedModule` in `core/elements/src/legacy-impl.ts` import a doc's `suggestedImportUrl` after a `window.confirm`. -- Plugin code is called at `createDocOfDatatype2` (`init`, `core/plugins/src/datatypes.ts`), the router (`getTitle`, `core/patchwork/src/router.ts`), tool render (`core/elements/src/legacy-impl.ts`) and component render (`core/elements/src/patchwork-view.ts`). -- Files from documents are served by the automerge SharedWorker's resolver (`core/bootloader/src/resolver.ts`), reached through the service worker's `/automerge%3A…` URLs (`core/bootloader/src/service-worker.ts`). - -## Decisions - -**Check whole packages.** The unit is a package root doc at specific heads, which is the first path segment of every module URL (`/automerge%3A%23/…`). Relative imports inside a package keep that segment, so one check covers the whole package. - -**Not in the plugin registry.** By the time `registerPlugins` is called, the entry point has already been imported, by the module-loader worker's discovery pass or by `#importSuggestedModule`. A check in the registry would only stop plugin implementations, not the package's top-level code. - -**An `onBeforeLoad` hook inside `importPackage*`.** It's set through a module-level setter in `@inkandswitch/patchwork-filesystem`, which `patchwork` calls during startup. Roughly: - -```ts -onBeforeLoad?: (pkg: { - url: string; // automerge url at heads, or http(s) url - source: "settings" | "suggested" | "plugin-import" | "package"; -}) => boolean | Promise; -``` - -`@inkandswitch/patchwork-filesystem` is an import-map external (`core/bootloader/src/externals-list.ts`), so every package shares the same `importPackage`. That makes it the approved way for one package to import another, instead of a raw relative `import("../base/packages/…")`. The `window.confirm` in front of suggested imports becomes the default hook for the `"suggested"` source. A plugin descriptor's `import` string can point outside its own package, so `registry.load` either runs the hook on that URL or requires it to stay inside the package that registered it. - -The hook can't really know who is asking. A package can pass its `import.meta.url`, but same-origin code can lie about it, so the policy should treat that as a hint and not rely on it. - -**The resolver enforces approvals.** Before calling `import()`, `importPackage` tells the resolver that this package at these heads is approved, and waits for the resolver to acknowledge it. The resolver refuses `script`, `worker` and `sharedworker` requests under any package that hasn't been approved. That catches `../` climbs into another package, which land on a different package and get checked on their own. - -- A refused request returns `abort`, which becomes a network error. The browser remembers a failed import for that URL in that realm, so the approval has to be acknowledged before `import()` runs. Otherwise a race leaves a failure memoized for a URL that was about to be approved. -- Refuse both paths: the `cache.put` path and the inline path that serves responses which aren't pinned. Refusing only at `cache.put` would still serve unapproved code, just without storing it. -- Data requests (`package.json` and the heads lookup in `fetchLatestViaServiceWorker`) stay allowed, because the hook needs them to find the heads it's being asked about. -- Don't write anything to the cache unless it passed the check. The service worker serves cache hits without asking the resolver, so being in the cache should mean it was checked. To revoke an approval, delete the cached entries under that package, or put a policy version in `cachename`. - -**Pin links when a package is published.** The heads on a URL pin only the doc it names. Links inside a folder are usually bare, so the files under a pinned root can still change, and `resolvePath` marks them `pinned: false` and they're never cached (see `Resolved.pinned` in `core/filesystem/src/resolve.ts`). Redirecting to pinned URLs doesn't fix this, because a module's identity is the URL that was requested, before the redirect, so a redirect leaves identity on the bare URL until the page reloads. Files reached by relative imports only become content-addressed if publishing writes links with heads, so that the root's heads cover everything below it. - -## Known gaps - -The gate keeps unchecked code out through the normal loading path. It does not contain code that's already running in the origin. - -- **Forging cache entries.** Any same-origin code can call `caches.open(cachename).put(...)` and write a forged response, and the service worker serves cache hits without asking the resolver. Refusing to cache unchecked responses does not prevent this. When the gate is built, add a comment at the resolver's `cache.put` and at the service worker's cache-hit path saying so. -- **Imports that bypass packages.** Without a CSP of `script-src 'self'` (no `data:`, `blob:`, `'unsafe-eval'`, `'wasm-unsafe-eval'` or remote hosts), code can still `import()` `data:`, `blob:` and remote URLs, use `new Function`, or instantiate wasm it got with `fetch`. -- **Calling the registry directly.** `registerPlugins` and `getRegistry` are importable, so a running package can register plugins without going through the hook. -- **Revoking an approval.** `unregisterPlugins(url)` removes a package's plugins from the registry, but code that has already run stays in memory until the page reloads. -- **What the module-loader worker can do.** Discovery runs package code in a same-origin worker, which can reach IndexedDB, the Cache API and `fetch`. The main thread has to run the hook before posting `discover`. - -Containing code that is already running needs a separate origin or a sandboxed iframe or worker.