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/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/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" 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" }) +})