Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
31 changes: 22 additions & 9 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = "0.17.3"
export const VERSION = "0.17.4"
3 changes: 2 additions & 1 deletion test/browser-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions test/browser/deny-default-worker.mjs
Original file line number Diff line number Diff line change
@@ -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()
}
}
29 changes: 29 additions & 0 deletions test/browser/deny-default.browser.ts
Original file line number Diff line number Diff line change
@@ -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<WorkerReport>((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" })
})
Loading