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
5 changes: 5 additions & 0 deletions .changeset/principal-writes-off-event-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@schlessera/brain-ui-server": patch
---

Changed: the hosted server commits authentication's per-request principal writes (the `last_seen_at` touch, terminal-principal pruning, ambient principal resolution and a login's new principal) on its SQLite write worker at `synchronous=FULL` instead of on the event loop (#2850). A request never waits for its touch; a login answers once its principal has committed.
4 changes: 2 additions & 2 deletions docs/decisions/claude-code-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ next to it.
of this binary. The only version probe in the server is for the `brain` CLI
(`Probe the selected content CLI`,
`packages/ui-server/src/brain/client.ts:142-210`, called at
`probeBrainCliVersion(config.brainPath`, `packages/ui-server/src/app.ts:433`).
`probeBrainCliVersion(config.brainPath`, `packages/ui-server/src/app.ts:434`).
`brain doctor` runs `claude mcp list` from `PATH` (`which("claude")`,
`packages/core/src/cli/commands/doctor.ts:503-507`) — the user's own Claude
Code on their own machine, to check the MCP registration, not the server's
Expand Down Expand Up @@ -212,7 +212,7 @@ unnecessary.
`node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`. The boot probe below
turns either into a refusal to start, for the same reason a missing backend
refuses to boot (`A missing (or unrecognized) agent backend`,
`packages/ui-server/src/app.ts:371-376`).
`packages/ui-server/src/app.ts:372-377`).
- **The published range is still a caret, and that bounds what this repo can
guarantee.** `@schlessera/brain-backend-claude` depends on `^0.3.241`, and a
host resolves it in its own lockfile. A host can bump the SDK — and so the
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ instead, and treat 8 as the question it was answering.
epoch. Logout and passkey revocation bump it **and close every open
WebSocket** (new `ClientSet.closeAll`). **The bump requires a valid
epoch-bearing session**: logout is a public route today
(`/auth/logout`, `packages/ui-server/src/middleware/auth.ts:806`,
(`/auth/logout`, `packages/ui-server/src/middleware/auth.ts:797`,
mounted before the guard), and a public epoch bump would let anyone log
the owner out of everything in a loop. The epoch accessor is strict, not
the fail-open settings getter: first initialization is distinguished from
Expand Down
8 changes: 4 additions & 4 deletions docs/decisions/http-api-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ explicit supported rows, not silently excluded by this distinction.

The source audit on 2026-09-30 used
`e0875269b21b707482545be9e94901acb897893b`. The public health mount precedes auth
(`createHealthRoutes`, `packages/ui-server/src/app.ts:864`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:885`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:1075`).
(`createHealthRoutes`, `packages/ui-server/src/app.ts:867`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:888`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:1078`).
Static serving is conditional (`if (options.staticRoot)`,
`packages/ui-server/src/app.ts:1095-1116`). That order establishes access;
`packages/ui-server/src/app.ts:1098-1119`). That order establishes access;
it does not select compatibility status.

A hermetic real-app launch, with temporary brain root, in-memory database,
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/scheduled-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Source inspected at `b5c30c180df5584584f261b446c4d440380d2441`:
(`else if (item.status === "scheduled"`, `packages/ui-server/src/inbox/actions.ts:196-197`).
The runtime ticks every minute (`INBOX_TICK_MS`, `packages/ui-server/src/inbox/runtime.ts:11-13`).
- Production app wiring supplies recovery and budgets, without a dispatcher
(`// Recovery/heartbeat only.`, `packages/ui-server/src/app.ts:491-507`).
(`// Recovery/heartbeat only.`, `packages/ui-server/src/app.ts:494-510`).
A scripted nonempty ready-item test still reports no claim, attempt or
Activity root. That proves disabled wiring, not safe execution of a schedule.
- Headless plumbing checks backend support, a usable principal, lifetime and
Expand Down
14 changes: 7 additions & 7 deletions docs/decisions/session-principals.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ incident; neither should be the standing answer.
- **Scopes / least privilege per route.** The one exception is R9's kind check on
the principal-management routes — a single check on a single route family, the
same shape as the existing password-disabled guard (`deps.passwordDisabled`,
`packages/ui-server/src/middleware/auth.ts:707`), not a scope system.
`packages/ui-server/src/middleware/auth.ts:698`), not a scope system.
- **Multi-user.** One owner, several principals.
- **Bearer tokens.** Additive later and cheap (see Q5); not in this plan.
- **Containment of a compromised principal.** See the Summary.
Expand Down Expand Up @@ -96,14 +96,14 @@ Both reviewers verified every row below against the source.
| Cookie verify | `auth.ts:265-296` | strict `^(\d+)\.(\d+)$`, safe integers, future/expired rejection, `cookieEpoch === sessionsEpoch(db)` |
| Global epoch | `auth.ts:302-318` | `settings` row; missing = 0; non-integer **throws** |
| Global revoke | `auth.ts:321-330` | `setSetting` (overflow-guarded) + `clients.closeAll(1008, …)` |
| Auth routes | `authRoutes`, `app.ts:870` | mounted **before** the guard at `authGuard(authMode`, `app.ts:885`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:808`) |
| Passkey management | `passkeyManagementRoutes`, `app.ts:895` | mounted **after** the guard ("Mount AFTER the auth guard") |
| WS guard | `"/ws"`, `app.ts:1075-1092` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade |
| Auth routes | `authRoutes`, `app.ts:873` | mounted **before** the guard at `authGuard(authMode`, `app.ts:888`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:799`) |
| Passkey management | `passkeyManagementRoutes`, `app.ts:898` | mounted **after** the guard ("Mount AFTER the auth guard") |
| WS guard | `"/ws"`, `app.ts:1078-1095` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade |
| WS upgrade | `ws/connection.ts:262-264` | `createWsUpgrade` ignores the request context |
| WS admission | `ws/connection.ts:84` | `clients.add(ws)` — the socket's identity is unknown |
| Turn record | `ws/run-session.ts:136-144` | recorder built from `{turnId, sessionId, billing}`; `RunningTurn` (`ws/turns.ts:55-77`) has no actor |
| Follow-up queue | `QueuedFollowUp`, `ws/turns.ts:61-103`; `slot.queue.push(entry)`, `run-session.ts:1121` | queued entries re-mint `turnId` in the same slot and may come from another socket |
| Password login | `acquirePasswordVerification(key)`, `auth.ts:749` | argon2id verify, failure counting, in-flight reservation, then `issueSessionCookie` |
| Password login | `acquirePasswordVerification(key)`, `auth.ts:740` | argon2id verify, failure counting, in-flight reservation, then `issueSessionCookie` |
| Passkey login | `issueLoginSession`, `packages/ui-server/src/middleware/passkeys.ts:448` | assertion verified, then `issueSessionCookie`; `row.id` is in scope |
| Passkey **registration** | `INSERT INTO passkey_credentials`, `passkeys.ts:547` | inserts a credential; calls **neither** helper |
| Passkey delete | `passkeys.ts:578-586` | deletes the credential, then a **global** bump |
Expand Down Expand Up @@ -163,7 +163,7 @@ edited without breaking the signature.
must not be able to drive database reads with arbitrary ids. The corrupt-row
throw sits after signature verification and outside the cookie-parser catch,
exactly as today's code does (`Deliberately after signature`,
`auth.ts:426-428`) — so a corrupt row 500s only that principal's requests
`auth.ts:416-418`) — so a corrupt row 500s only that principal's requests
instead of everyone's.

**4. Principals carry their lineage, and passkey deletion uses it.**
Expand Down Expand Up @@ -216,7 +216,7 @@ nothing is backfilled.
> example does not describe the current root span.

**8. Ambient modes keep the identity they already have.** `proxy` mode reads an
upstream user header (`auth.proxyAuthHeader`, `auth.ts:485`) — that becomes the
upstream user header (`auth.proxyAuthHeader`, `auth.ts:476`) — that becomes the
ambient principal's label, bounded like `sanitizeLabel`, `passkeys.ts:273-275`.
`tailscale` carries the client IP; `none` is fixed. Minting and revocation are
disabled in these modes. Cron is attributed to an explicit system principal, never to the owner.
Expand Down
12 changes: 10 additions & 2 deletions docs/decisions/sqlite-write-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,18 @@ are in [the governed-workers decision](governed-writable-workers.md#admitted-obj
still the host fallback afterwards. A type folder translation that cannot
be recorded refuses the turn. `holders()`, a diagnostic, still reads on
the gate's own connection.
- **Authentication's per-request writes followed** (#2850). A principal's
`last_seen_at` touch, terminal-principal pruning, ambient principal
resolution and a login's new principal commit on the worker
(`packages/ui-server/src/db/principal-writes.ts`). A touch is never waited
for and one pending is not submitted again; an ambient principal that is
current is read at once; a login answers once its row has committed, and
its refusals keep their classes. Revocations and administrative principal
writes still commit on the app connection.
- **Other app-connection writers stay on the loop for now**: retained
retries and local exchanges, saved worker profiles, policy
proposals, the Activity notifier and push ticks, auth sessions, inbox and
settings. Each commits at
proposals, the Activity notifier and push ticks, principal revocation and
administration, passkeys, inbox and settings. Each commits at
FULL on the loop, and while a worker procedure holds its turn, such a write
also waits there for the worker's commit to finish, including a journal
procedure that writes only `journal.db`, because the ledger's
Expand Down
4 changes: 2 additions & 2 deletions docs/http-api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The four named positive tests are `real WebSocket admission: untrusted proxy
accepts a matching HTTPS Origin`, `real WebSocket admission: trusted proxy
accepts forwarded HTTPS`, `real WebSocket admission: trusted proxy maps WSS to
HTTPS` and `real WebSocket admission: trusted proxy maps WS to HTTP`
(`real WebSocket admission:`, `packages/ui-server/tests/http-websocket-admission.test.ts:44-53`).
(`real WebSocket admission:`, `packages/ui-server/tests/http-websocket-admission.test.ts:44-55`).
An HTTP response other than 403 is insufficient evidence of admission.

The final column names detailed behavior checks below. Some existing checks
Expand Down Expand Up @@ -390,7 +390,7 @@ the additive `query_activity` scope carries the supported agent contract.

**W — WebSocket admission.**

- `real WebSocket admission rejects origin, authentication and capacity before upgrading` ([source](../packages/ui-server/tests/http-websocket-admission.test.ts)), (`real WebSocket admission rejects origin, authentication and capacity before upgrading`, `packages/ui-server/tests/http-websocket-admission.test.ts:56-87`)
- `real WebSocket admission rejects origin, authentication and capacity before upgrading` ([source](../packages/ui-server/tests/http-websocket-admission.test.ts)), (`real WebSocket admission rejects origin, authentication and capacity before upgrading`, `packages/ui-server/tests/http-websocket-admission.test.ts:58-89`)

**Z — Object-only validation and retained defaults.**

Expand Down
2 changes: 1 addition & 1 deletion docs/plans/async-collaboration-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ Stated before the requirements because five of them derive from it.
that is the shape to copy, including its `close()` lifecycle.
- R19. **The cron backstop has independent authorization before the general guard.**
Mount it on the existing listener before
(`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:885`).
(`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:888`).
Authorize a boot-minted ephemeral token, rotated each boot and stored in a
0600 runtime file, with the actual socket address as an additional check.
Proxy headers cannot authorize it. The poke succeeds in every auth mode
Expand Down
4 changes: 2 additions & 2 deletions docs/plans/async-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ system gate. It is not a prerequisite to rebuild or a new independent package.
owns manual triggers/history; it does not tick the inbox.
- **Two connections:** (`export function createUiDb(`, `packages/ui-server/src/db/client.ts:27-44`) sets WAL,
foreign keys and a 5-second busy timeout. Claims are immediate transactions.
- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:885`) follows public routes;
(`export function authGuard(`, `packages/ui-server/src/middleware/auth.ts:189-249`) binds principals in each auth mode.
- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:888`) follows public routes;
(`export function authGuard(`, `packages/ui-server/src/middleware/auth.ts:185-245`) binds principals in each auth mode.
An internal poke needs independent token authorization before this guard.
- **Permission parking:** (`requestPermission: (req) => {`, `packages/ui-server/src/ws/bridge.ts:358-452`). Timeout unwind is
(`abortController.abort();`, `packages/ui-server/src/ws/run-session.ts:393-399`). Durable escalation must checkpoint
Expand Down
2 changes: 1 addition & 1 deletion docs/plans/navigation-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ implementation. Any PR moving these lines owns their citation repairs.

- Activity resolution: (`.get("/activity/runs", (c) => {`, `packages/ui-server/src/routes/activity.ts:143-281`).

- API authorization: (`app.use("/api/*", authGuard(authMode, auth, db));`, `packages/ui-server/src/app.ts:885`).
- API authorization: (`app.use("/api/*", authGuard(authMode, auth, db));`, `packages/ui-server/src/app.ts:888`).

- Queued acceptance: (`// Queue it as the session's next turn; report queued immediately.`, `packages/ui-server/src/ws/run-session.ts:1098-1143`).

Expand Down
2 changes: 1 addition & 1 deletion docs/plans/storage-accounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Source anchors, checked against the reviewed commit:
The completed #94 and #398 provide existing stats/maintenance foundations.
- C: snapshot fields (`snapshotOf`, `packages/core/src/lib/stats-history.ts:132-172`),
thinning (`thin`, `packages/core/src/lib/stats-history.ts:232-244`).
- D: effective path/journal resolution (`createApp`, `packages/ui-server/src/app.ts:292-328`),
- D: effective path/journal resolution (`createApp`, `packages/ui-server/src/app.ts:293-329`),
WAL store (`createUiDb`, `packages/ui-server/src/db/client.ts:27-44`), and
private locations (`statePaths`, `packages/ui-server/src/brain/operational-state.ts:108-114`).
- E: live/archive paths (`transcriptPaths`, `packages/ui-backend-claude/src/transcript-archive.ts:30-39`).
Expand Down
3 changes: 3 additions & 0 deletions packages/ui-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import { principalManagementRoutes } from "./middleware/principals.js";
import { createModuleRoutes } from "./routes/modules.js";
import { createUiDb } from "./db/client.js";
import { openSqliteWriter } from "./db/write-worker.js";
import { registerPrincipalWrites } from "./db/principal-writes.js";
import { JOURNAL_SCHEMA, journalDatabasePath } from "./db/journal-database.js";
import { createInboxBridgeTools } from "./inbox/bridge-tools.js";
import { createInboxStore } from "./inbox/store.js";
Expand Down Expand Up @@ -452,6 +453,8 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BrainUi
// Journal and Activity commits run on this worker's connection, off the event loop (#2814).
// It owns the journal's own file too (#2789), attached as it is here.
const writer = openSqliteWriter(db, { attach: [{ schema: JOURNAL_SCHEMA, path: journalDatabasePath(config.dbPath) }] });
// Authentication's per-request principal writes commit on it too (#2850).
registerPrincipalWrites(db, writer);
bootCleanup.push(() => writer.close());
reconciled(hostExecution.attach(db, writer));
// Shared reference source (#2429) runs only where the native manager owns a reference pool.
Expand Down
Loading
Loading