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/account-admin-write-worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@schlessera/brain-ui-server": patch
---

Commit account and security administration, passkeys, reference grants and push registration on the SQLite write worker at FULL durability, so their fsyncs do not block requests. Revocation responses await the durable revocation, and concurrent VAPID first uses share one stored key pair.
2 changes: 1 addition & 1 deletion docs/decisions/action-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ incrementing an Action count
(`function createIntent(input: {`, `packages/ui-server/src/activity/notify.ts:94-130`).
The sender processes run-bound activity rows and settles one intent from a
device pass; that is not the required per-episode/per-destination Action history
(`async deliverPending(notifier) {`, `packages/ui-server/src/activity/push-sender.ts:208-282`).
(`async deliverPending(notifier) {`, `packages/ui-server/src/activity/push-sender.ts:172-246`).
Reuse concrete subscriptions, authorization, revocation and bounded backoff,
but add the Actions-aware aggregate and durable constituent/destination records.
This decision authorizes no new channel, extension seam or daemon.
Expand Down
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:437`).
`probeBrainCliVersion(config.brainPath`, `packages/ui-server/src/app.ts:438`).
`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:375-380`).
`packages/ui-server/src/app.ts:376-381`).
- **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:797`,
(`/auth/logout`, `packages/ui-server/src/middleware/auth.ts:796`,
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:877`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:898`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:1088`).
(`createHealthRoutes`, `packages/ui-server/src/app.ts:879`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:900`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:1090`).
Static serving is conditional (`if (options.staticRoot)`,
`packages/ui-server/src/app.ts:1108-1129`). That order establishes access;
`packages/ui-server/src/app.ts:1110-1131`). That order establishes access;
it does not select compatibility status.

A hermetic real-app launch, with temporary brain root, in-memory database,
Expand Down
4 changes: 2 additions & 2 deletions docs/decisions/reference-source-grants.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Only a live owner can create a grant, and only for a live owner or agent
principal. Cron's system principal and ambient principals cannot hold one. The
routes sit inside the principal-management family, so they inherit its
password-mode and owner checks
(`app.get("/auth/principals/:id/reference-grants"`, `packages/ui-server/src/middleware/principals.ts:219-228`).
(`app.get("/auth/principals/:id/reference-grants"`, `packages/ui-server/src/middleware/principals.ts:216-225`).
Owner sessions are principals too: every login creates one, and a grant to an
owner session ends when that session does.

Expand Down Expand Up @@ -78,7 +78,7 @@ anonymous success there says nothing about who may read the host path.
Revocation is checked, not cached. Every new mount and every step that could
publish private source asks for the live covering grant again. The revoke
routes also report the lost authority synchronously
(`app.delete("/auth/principals/:id/reference-grants/:grantId"`, `packages/ui-server/src/middleware/principals.ts:275-296`),
(`app.delete("/auth/principals/:id/reference-grants/:grantId"`, `packages/ui-server/src/middleware/principals.ts:272-293`),
so the acquisition layer can cancel in-flight private work rather than wait for
its next check. Revocation cannot retract bytes a reader has already read.

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:499-515`).
(`// Recovery/heartbeat only.`, `packages/ui-server/src/app.ts:501-517`).
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
18 changes: 9 additions & 9 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:698`), not a scope system.
`packages/ui-server/src/middleware/auth.ts:697`), 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,16 +96,16 @@ 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:883` | mounted **before** the guard at `authGuard(authMode`, `app.ts:898`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:799`) |
| Passkey management | `passkeyManagementRoutes`, `app.ts:908` | mounted **after** the guard ("Mount AFTER the auth guard") |
| WS guard | `"/ws"`, `app.ts:1088-1105` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade |
| Auth routes | `authRoutes`, `app.ts:885` | mounted **before** the guard at `authGuard(authMode`, `app.ts:900`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:798`) |
| Passkey management | `passkeyManagementRoutes`, `app.ts:910` | mounted **after** the guard ("Mount AFTER the auth guard") |
| WS guard | `"/ws"`, `app.ts:1090-1107` | 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: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 |
| Password login | `acquirePasswordVerification(key)`, `auth.ts:739` | argon2id verify, failure counting, in-flight reservation, then `issueSessionCookie` |
| Passkey login | `issueLoginSession`, `packages/ui-server/src/middleware/passkeys.ts:441` | assertion verified, then `issueSessionCookie`; `row.id` is in scope |
| Passkey **registration** | `INSERT INTO passkey_credentials`, `packages/ui-server/src/db/account-writes.ts:31-36` | inserts a credential; calls **neither** helper |
| Passkey delete | `passkeys.ts:578-586` | deletes the credential, then a **global** bump |
| Sockets | `ClientSet`, `ws/clients.ts:41` | `ClientSet` keyed on `ws.raw` — hono mints a fresh `WSContext` per callback |
| Activity subscriptions | `activity/stream.ts:153` | a **separate** registry keyed on the wrapper, not `ws.raw` |
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:416-418`) — so a corrupt row 500s only that principal's requests
`auth.ts:415-417`) — 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:476`) — that becomes the
upstream user header (`auth.proxyAuthHeader`, `auth.ts:475`) — 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
31 changes: 20 additions & 11 deletions docs/decisions/sqlite-write-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,26 @@ are in [the governed-workers decision](governed-writable-workers.md#admitted-obj
(`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.
its refusals keep their classes. Account administration uses the same worker,
as described below.
- **Account and security administration followed** (#3002, within #2850).
Delegated-principal creation and revocation, passkey registration, counters,
labels and deletion, reference grants and the account partition seed commit
on the worker (`packages/ui-server/src/db/account-writes.ts`). The existing
transaction bodies and modes stay together; passkey deletion still commits
before its credential's principal revocation. Revocation routes await both
durable revocation and push unbinding before answering; client authority is
invalidated as soon as revocation commits. Every authentication after the
response sees the committed revocation. Delegated minting
and reference grants recheck authority inside their original transactions;
refusals keep their classes and HTTP statuses. Boot awaits the partition seed
before constructing its synchronous key reader. Push subscription, removal
and unbinding, and first-use VAPID creation run on the same queue
(`packages/ui-server/src/activity/push-writes.ts`). Each VAPID caller checks
for the stored pair on that connection, so racing delivery passes and the
key route share one insert and its read-back. Delivery still joins devices
to live principals while runtime unbinding is pending. Standalone callers
retain the synchronous procedures on their own connection.
- **The Activity notifier followed** (#2850). Its tick (intent detection
under its cursor and the stuck-run watchdog), delivery status,
acknowledgement and retention, and push delivery's subscription
Expand Down Expand Up @@ -341,15 +359,6 @@ are in [the governed-workers decision](governed-writable-workers.md#admitted-obj
the same ordering relative to later work on that worktree. Callers without a
registered writer run the same procedures inline. Boot-only source recovery
retains its inline catalog writes.
- **Other app-connection writers stay on the loop for now**: push
subscriptions and VAPID keys, principal revocation and administration
and passkeys. 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
`BEGIN IMMEDIATE` locks every attached file. Moving each of them to the
worker queue ends that wait too
([#2850](https://github.com/schlessera/brain-kit/issues/2850)).

## Rejected

Expand Down
2 changes: 1 addition & 1 deletion docs/inbox-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ deadline opens the next window. Thread identity is kept on each constituent,
not in the grouping key. Unmarked below-cutoff decisions and FYIs never join a window.

Delivery runs after activity intents on the same reentrancy guard
(`deliverActions`, `packages/ui-server/src/activity/push-sender.ts:327-378`).
(`deliverActions`, `packages/ui-server/src/activity/push-sender.ts:291-342`).
Each destination is evaluated on its own. `beginAttempt()` rechecks, in one
transaction, that the subscription is still bound to the principal and the
principal is usable, that the device reported a usable zone and is outside
Expand Down
4 changes: 2 additions & 2 deletions 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:898`).
(`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:900`).
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 Expand Up @@ -387,7 +387,7 @@ Stated before the requirements because five of them derive from it.
to `failure|completion|stuck`, requires an activity `run_id`, coalesces only by dropping a
later same-tag intent without updating a count
(`function createIntent(input: {`, `packages/ui-server/src/activity/notify.ts:94-130`), and the sender emits one push per
pending row (`async deliverPending(notifier) {`, `packages/ui-server/src/activity/push-sender.ts:208-255`). Reuse subscriptions, retry budget, and delivery
pending row (`async deliverPending(notifier) {`, `packages/ui-server/src/activity/push-sender.ts:172-219`). Reuse subscriptions, retry budget, and delivery
status; add an Actions-aware aggregate carrying group key, count, priority, quiet-hours
eligibility, and an Actions deep link. The
[2026-10-02 Action notification decision](../decisions/action-notifications.md)
Expand Down
6 changes: 3 additions & 3 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:898`) follows public routes;
(`export function authGuard(`, `packages/ui-server/src/middleware/auth.ts:185-245`) binds principals in each auth mode.
- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:900`) follows public routes;
(`export function authGuard(`, `packages/ui-server/src/middleware/auth.ts:184-244`) 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:361-455`). Timeout unwind is
(`abortController.abort();`, `packages/ui-server/src/ws/run-session.ts:393-399`). Durable escalation must checkpoint
Expand All @@ -151,7 +151,7 @@ system gate. It is not a prerequisite to rebuild or a new independent package.
Reservations must cover in-flight work, not only this retrospective sum.
- **Notifications:** (`CREATE TABLE IF NOT EXISTS notification_intents (`, `packages/ui-server/migrations/007_activity.sql:99-114`),
(`function createIntent(input: {`, `packages/ui-server/src/activity/notify.ts:94-130`) and
(`async deliverPending(notifier) {`, `packages/ui-server/src/activity/push-sender.ts:208-255`) are run-bound and do not
(`async deliverPending(notifier) {`, `packages/ui-server/src/activity/push-sender.ts:172-219`) are run-bound and do not
maintain an Actions aggregate count.
- **Share provenance/limits:** (`const result = await stageShareAt(`, `packages/ui-server/src/inbox/intake.ts:101`) assigns the source in server code.
(`export const SHARE_MAX_TEXT_BYTES =`, `packages/ui-sdk/src/protocol.ts:2348`) bounds text, not binary uploads;
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:145-283`).

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

- 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:296-332`),
- D: effective path/journal resolution (`createApp`, `packages/ui-server/src/app.ts:297-333`),
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
2 changes: 1 addition & 1 deletion packages/ui-react/tests/browser/offline/fault-network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* - `expireAuth()`: the probe and every request answer 401, open sockets close
* with 1008 (the host's expiry and revocation close:
* `SESSION_EXPIRED_CLOSE_CODE`, `packages/ui-server/src/ws/host.ts:54`;
* `SESSION_REVOKED_CLOSE_CODE`, `packages/ui-server/src/middleware/auth.ts:66`),
* `SESSION_REVOKED_CLOSE_CODE`, `packages/ui-server/src/middleware/auth.ts:65`),
* and new sockets fail their handshake, as an upgrade without a session does.
*
* Pass `network.request` to `createBrainUiRoot({ request })`. The global
Expand Down
Loading
Loading