From 997e525dfca86e521c6488ca8cf808da81e01ad7 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Thu, 8 Oct 2026 12:10:22 -0700 Subject: [PATCH 1/2] docs: say that browser policies deny by default In the browser evaluation, two of 16 agent answers gave the browser install and worker setup with no authorization policy. Both read that a browser policy is not a security boundary and dropped the deny-by-default rule, so the worker they described would answer no call. Step 12 now says plainly that every callback denies by default in the browser too, the install step points to the policy and to runtime.run(signal), and the checks start with the policy. A new Chromium test proves that a worker with no callbacks rejects calls with Unauthorized; with authorizeMessage set, the same test fails. --- docs/agents.md | 31 ++++++++++++++++++++-------- test/browser-server.mjs | 3 ++- test/browser/deny-default-worker.mjs | 31 ++++++++++++++++++++++++++++ test/browser/deny-default.browser.ts | 29 ++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 test/browser/deny-default-worker.mjs create mode 100644 test/browser/deny-default.browser.ts diff --git a/docs/agents.md b/docs/agents.md index c8bb103..f4b0cca 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -361,6 +361,11 @@ npm install solid-objects @sqlite.org/sqlite-wasm `@sqlite.org/sqlite-wasm` 3.50 or newer is an optional peer dependency. The browser runtime needs it. +Every authorization callback denies by default in the browser too. Write the +policy in [Authorize in the browser](#authorize-in-the-browser) before you call +an actor. Reminders and effects run only while the worker calls +`runtime.run(signal)`. + ### Choose the entry point | Need | Use | @@ -464,10 +469,16 @@ and calls time out before it expires. ### Authorize in the browser -The page and the worker run on the user's device, and the user can change -their code. A browser policy limits what your own page can call. It is not a -security boundary. Authorize again on the server for each write that leaves the -device. +All authorization callbacks deny by default, in the browser as in Node.js. A +worker that sets no `authorizeMessage` and no `authorizeQuery` answers no call. +Each call fails with `Unauthorized`. Allow the actor types that your page uses, +as `allowNoteDrafts` does in the example. Do not remove the deny-by-default +behavior. + +The browser policy is still not a security boundary. The page and the worker +run on the user's device, and the user can change their code. The policy limits +what your own page can call. Authorize again on the server for each write that +leaves the device. ### Rules for browser actors @@ -552,10 +563,12 @@ The [public API](api.md#solid-objectstransmit) and the ### Verify a browser implementation -1. Call an operation, reload the page, and confirm that the state is the same. -2. Send concurrent calls to one identity from two tabs. Assert the final state. -3. Close the tab that holds the database. Confirm that the other tab continues. -4. On each target engine, confirm that persistent storage opens, or fails with +1. Confirm that the worker sets `authorizeMessage` and `authorizeQuery`. + Without them, every call fails with `Unauthorized`. +2. Call an operation, reload the page, and confirm that the state is the same. +3. Send concurrent calls to one identity from two tabs. Assert the final state. +4. Close the tab that holds the database. Confirm that the other tab continues. +5. On each target engine, confirm that persistent storage opens, or fails with a clear error. -5. If you send writes to a server, confirm that the server rejects a device +6. If you send writes to a server, confirm that the server rejects a device that it cannot authenticate, and applies a repeated write once. diff --git a/test/browser-server.mjs b/test/browser-server.mjs index 8c659c4..f19d1ec 100644 --- a/test/browser-server.mjs +++ b/test/browser-server.mjs @@ -116,7 +116,8 @@ const server = createServer(async (request, response) => { pathname === "/tab-host-worker.mjs" || pathname === "/transmit-worker.mjs" || pathname === "/shared-db-worker.mjs" || - pathname === "/live-signals-worker.mjs" + pathname === "/live-signals-worker.mjs" || + pathname === "/deny-default-worker.mjs" ) { await serveFile({ response, path: resolve(browserFixtureRoot, pathname.slice(1)) }) return diff --git a/test/browser/deny-default-worker.mjs b/test/browser/deny-default-worker.mjs new file mode 100644 index 0000000..1b4f878 --- /dev/null +++ b/test/browser/deny-default-worker.mjs @@ -0,0 +1,31 @@ +import { Actor, configure, sqliteWasm } from "/browser/host.js" + +class DenyCounter extends Actor { + static actorType = "DenyCounter" + + count = 0 + + increment() { + this.count += 1 + return this.count + } +} + +self.onmessage = async () => { + const database = await sqliteWasm({ path: "deny-default.db" }) + const runtime = configure({ database }) + try { + await runtime.install() + const value = await DenyCounter.ref("one").increment() + postMessage({ ok: true, value }) + } catch (error) { + postMessage({ + ok: false, + name: error?.constructor?.name, + message: String(error?.message ?? error), + }) + } finally { + await runtime.close() + await database.close() + } +} diff --git a/test/browser/deny-default.browser.ts b/test/browser/deny-default.browser.ts new file mode 100644 index 0000000..5e29435 --- /dev/null +++ b/test/browser/deny-default.browser.ts @@ -0,0 +1,29 @@ +import { expect, test } from "@playwright/test" + +interface WorkerReport { + ok: boolean + name?: string + message?: string +} + +test("a browser worker with no authorization callbacks rejects actor calls", async ({ page }) => { + await page.goto("/") + + const report = await page.evaluate( + () => + new Promise((resolve, reject) => { + const worker = new Worker("/deny-default-worker.mjs", { type: "module" }) + worker.onmessage = (event) => { + worker.terminate() + resolve(event.data as WorkerReport) + } + worker.onerror = (event) => { + worker.terminate() + reject(new Error(event.message)) + } + worker.postMessage({}) + }), + ) + + expect(report).toMatchObject({ ok: false, name: "Unauthorized" }) +}) From e61ff64ae328409a909b5cc338a39d9c3ef590b3 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Thu, 8 Oct 2026 12:10:22 -0700 Subject: [PATCH 2/2] chore: prepare version 0.17.4 --- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- src/version.ts | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 244c8c2..526b1c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.17.4 - 2026-10-08 + +- Step 12 of `docs/agents.md` now says plainly that every authorization + callback denies by default in the browser too. A worker with no + `authorizeMessage` and no `authorizeQuery` answers no call. The install + step points to the browser policy and to `runtime.run(signal)`, and the + checks start with the policy. In an evaluation, two agent answers gave the + browser install with no policy after they read that a browser policy is + not a security boundary. +- A new Chromium test proves that a browser worker with no authorization + callbacks rejects actor calls with `Unauthorized`. + ## 0.17.3 - 2026-10-08 - `docs/agents.md` has a new step 12 for the browser runtime. It covers when diff --git a/package.json b/package.json index bb06a7c..e5a4339 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.17.3", + "version": "0.17.4", "description": "SQL-backed virtual actor library for TypeScript and Node.js, with durable state, ordered operations, and automatic activation on SQLite, PostgreSQL, or MySQL", "type": "module", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index e589300..1e87784 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.17.3" +export const VERSION = "0.17.4"