@schlessera/brain-ui-server 0.34.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.md +28 -0
  2. package/dist/activity/notify.d.ts.map +1 -1
  3. package/dist/activity/notify.js +18 -8
  4. package/dist/activity/notify.js.map +1 -1
  5. package/dist/activity/push-sender.d.ts +7 -3
  6. package/dist/activity/push-sender.d.ts.map +1 -1
  7. package/dist/activity/push-sender.js +45 -11
  8. package/dist/activity/push-sender.js.map +1 -1
  9. package/dist/activity/query.d.ts.map +1 -1
  10. package/dist/activity/query.js +8 -0
  11. package/dist/activity/query.js.map +1 -1
  12. package/dist/activity/recorder.d.ts +11 -1
  13. package/dist/activity/recorder.d.ts.map +1 -1
  14. package/dist/activity/recorder.js +52 -2
  15. package/dist/activity/recorder.js.map +1 -1
  16. package/dist/activity/runtime.d.ts.map +1 -1
  17. package/dist/activity/runtime.js +2 -5
  18. package/dist/activity/runtime.js.map +1 -1
  19. package/dist/activity/sql.d.ts +3 -3
  20. package/dist/activity/sql.d.ts.map +1 -1
  21. package/dist/activity/sql.js +7 -5
  22. package/dist/activity/sql.js.map +1 -1
  23. package/dist/activity/store.d.ts +7 -0
  24. package/dist/activity/store.d.ts.map +1 -1
  25. package/dist/activity/store.js +15 -3
  26. package/dist/activity/store.js.map +1 -1
  27. package/dist/activity/stream.d.ts +3 -1
  28. package/dist/activity/stream.d.ts.map +1 -1
  29. package/dist/activity/stream.js +32 -17
  30. package/dist/activity/stream.js.map +1 -1
  31. package/dist/app-env.d.ts +8 -0
  32. package/dist/app-env.d.ts.map +1 -0
  33. package/dist/app-env.js +2 -0
  34. package/dist/app-env.js.map +1 -0
  35. package/dist/app.d.ts.map +1 -1
  36. package/dist/app.js +28 -3
  37. package/dist/app.js.map +1 -1
  38. package/dist/brain/client.d.ts +4 -2
  39. package/dist/brain/client.d.ts.map +1 -1
  40. package/dist/brain/client.js +60 -20
  41. package/dist/brain/client.js.map +1 -1
  42. package/dist/brain/types.d.ts +9 -0
  43. package/dist/brain/types.d.ts.map +1 -1
  44. package/dist/cron/run-job.d.ts.map +1 -1
  45. package/dist/cron/run-job.js +60 -10
  46. package/dist/cron/run-job.js.map +1 -1
  47. package/dist/db/principals.d.ts +72 -0
  48. package/dist/db/principals.d.ts.map +1 -0
  49. package/dist/db/principals.js +294 -0
  50. package/dist/db/principals.js.map +1 -0
  51. package/dist/files/walker.d.ts.map +1 -1
  52. package/dist/files/walker.js +6 -1
  53. package/dist/files/walker.js.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/middleware/auth.d.ts +37 -9
  59. package/dist/middleware/auth.d.ts.map +1 -1
  60. package/dist/middleware/auth.js +148 -74
  61. package/dist/middleware/auth.js.map +1 -1
  62. package/dist/middleware/passkeys.d.ts +9 -7
  63. package/dist/middleware/passkeys.d.ts.map +1 -1
  64. package/dist/middleware/passkeys.js +25 -7
  65. package/dist/middleware/passkeys.js.map +1 -1
  66. package/dist/middleware/principals.d.ts +18 -0
  67. package/dist/middleware/principals.d.ts.map +1 -0
  68. package/dist/middleware/principals.js +138 -0
  69. package/dist/middleware/principals.js.map +1 -0
  70. package/dist/middleware/require-owner.d.ts +5 -0
  71. package/dist/middleware/require-owner.d.ts.map +1 -0
  72. package/dist/middleware/require-owner.js +10 -0
  73. package/dist/middleware/require-owner.js.map +1 -0
  74. package/dist/observability/loggers.js +1 -1
  75. package/dist/observability/loggers.js.map +1 -1
  76. package/dist/routes/activity.d.ts.map +1 -1
  77. package/dist/routes/activity.js +3 -0
  78. package/dist/routes/activity.js.map +1 -1
  79. package/dist/routes/brain.d.ts.map +1 -1
  80. package/dist/routes/brain.js +102 -63
  81. package/dist/routes/brain.js.map +1 -1
  82. package/dist/routes/files.d.ts.map +1 -1
  83. package/dist/routes/files.js +7 -2
  84. package/dist/routes/files.js.map +1 -1
  85. package/dist/routes/push.d.ts +61 -2
  86. package/dist/routes/push.d.ts.map +1 -1
  87. package/dist/routes/push.js +8 -3
  88. package/dist/routes/push.js.map +1 -1
  89. package/dist/routes/sessions.d.ts.map +1 -1
  90. package/dist/routes/sessions.js +30 -4
  91. package/dist/routes/sessions.js.map +1 -1
  92. package/dist/share/staging.d.ts.map +1 -1
  93. package/dist/share/staging.js +11 -4
  94. package/dist/share/staging.js.map +1 -1
  95. package/dist/ws/bridge.d.ts.map +1 -1
  96. package/dist/ws/bridge.js +6 -2
  97. package/dist/ws/bridge.js.map +1 -1
  98. package/dist/ws/clients.d.ts +9 -2
  99. package/dist/ws/clients.d.ts.map +1 -1
  100. package/dist/ws/clients.js +44 -6
  101. package/dist/ws/clients.js.map +1 -1
  102. package/dist/ws/connection.d.ts +7 -4
  103. package/dist/ws/connection.d.ts.map +1 -1
  104. package/dist/ws/connection.js +83 -12
  105. package/dist/ws/connection.js.map +1 -1
  106. package/dist/ws/dispatch.d.ts +12 -1
  107. package/dist/ws/dispatch.d.ts.map +1 -1
  108. package/dist/ws/dispatch.js +46 -6
  109. package/dist/ws/dispatch.js.map +1 -1
  110. package/dist/ws/history.d.ts.map +1 -1
  111. package/dist/ws/history.js +7 -5
  112. package/dist/ws/history.js.map +1 -1
  113. package/dist/ws/host.d.ts +22 -0
  114. package/dist/ws/host.d.ts.map +1 -1
  115. package/dist/ws/host.js +39 -1
  116. package/dist/ws/host.js.map +1 -1
  117. package/dist/ws/run-session.d.ts +7 -7
  118. package/dist/ws/run-session.d.ts.map +1 -1
  119. package/dist/ws/run-session.js +208 -52
  120. package/dist/ws/run-session.js.map +1 -1
  121. package/dist/ws/shrink.d.ts +3 -1
  122. package/dist/ws/shrink.d.ts.map +1 -1
  123. package/dist/ws/shrink.js +46 -16
  124. package/dist/ws/shrink.js.map +1 -1
  125. package/dist/ws/turns.d.ts +58 -3
  126. package/dist/ws/turns.d.ts.map +1 -1
  127. package/dist/ws/turns.js +101 -2
  128. package/dist/ws/turns.js.map +1 -1
  129. package/migrations/011_principals.sql +13 -0
  130. package/migrations/012_activity_principals.sql +12 -0
  131. package/migrations/013_push_subscription_principals.sql +7 -0
  132. package/migrations/014_invalidate_legacy_sessions.sql +14 -0
  133. package/package.json +3 -3
  134. package/src/activity/notify.ts +16 -7
  135. package/src/activity/push-sender.ts +79 -16
  136. package/src/activity/query.ts +8 -0
  137. package/src/activity/recorder.ts +68 -3
  138. package/src/activity/runtime.ts +2 -5
  139. package/src/activity/sql.ts +7 -5
  140. package/src/activity/store.ts +32 -1
  141. package/src/activity/stream.ts +37 -17
  142. package/src/app-env.ts +8 -0
  143. package/src/app.ts +33 -4
  144. package/src/brain/client.ts +61 -20
  145. package/src/brain/types.ts +10 -0
  146. package/src/cron/run-job.ts +41 -17
  147. package/src/db/principals.ts +452 -0
  148. package/src/files/walker.ts +5 -1
  149. package/src/index.ts +6 -1
  150. package/src/middleware/auth.ts +241 -81
  151. package/src/middleware/passkeys.ts +35 -14
  152. package/src/middleware/principals.ts +189 -0
  153. package/src/middleware/require-owner.ts +13 -0
  154. package/src/observability/loggers.ts +1 -1
  155. package/src/routes/activity.ts +3 -0
  156. package/src/routes/brain.ts +107 -70
  157. package/src/routes/files.ts +10 -5
  158. package/src/routes/push.ts +11 -5
  159. package/src/routes/sessions.ts +45 -20
  160. package/src/share/staging.ts +11 -4
  161. package/src/ws/bridge.ts +14 -2
  162. package/src/ws/clients.ts +52 -7
  163. package/src/ws/connection.ts +98 -20
  164. package/src/ws/dispatch.ts +66 -6
  165. package/src/ws/history.ts +7 -5
  166. package/src/ws/host.ts +59 -1
  167. package/src/ws/run-session.ts +247 -74
  168. package/src/ws/shrink.ts +48 -14
  169. package/src/ws/turns.ts +136 -5
@@ -8,8 +8,23 @@ import { isTailscaleAllowed, clientIp } from "./tailscale.js";
8
8
  import type { AuthConfig } from "../config/env.js";
9
9
  import { readJsonBody } from "./body-limit.js";
10
10
  import { requireJson } from "./origin.js";
11
- import { setSetting } from "../db/settings.js";
12
- import type { ClientSet } from "../ws/clients.js";
11
+ import {
12
+ createPrincipal,
13
+ isCookieBearingPrincipal,
14
+ isUsablePrincipal,
15
+ PrincipalCredentialNotFoundError,
16
+ PrincipalLimitError,
17
+ prunePrincipals,
18
+ prunePrincipalsIfDue,
19
+ resolveAmbientPrincipal,
20
+ resolvePrincipal,
21
+ revokePrincipal,
22
+ revokeAllPrincipals,
23
+ touchLastSeen,
24
+ type Principal,
25
+ } from "../db/principals.js";
26
+ import type { AppEnv } from "../app-env.js";
27
+ import type { WsHost } from "../ws/host.js";
13
28
 
14
29
  /**
15
30
  * Authentication for a remote surface to an agent with write access to the
@@ -44,10 +59,13 @@ export interface AuthRuntime extends AuthConfig {
44
59
  }
45
60
 
46
61
  const COOKIE_NAME = "brain_ui_session";
47
- const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
48
- const SESSIONS_EPOCH_KEY = "auth.sessionsEpoch";
62
+ export const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
63
+ const LOGIN_LABEL_MAX_LENGTH = 64;
64
+ const LOGIN_LABEL_FALLBACK = "Unknown device";
65
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
49
66
  const SESSION_REVOKED_CLOSE_CODE = 1008;
50
67
  const SESSION_REVOKED_CLOSE_REASON = "Sessions invalidated";
68
+
51
69
  export const LOGIN_RATE_LIMIT = 5; // failures per window, per client IP
52
70
  const LOGIN_RATE_WINDOW_MS = 60_000; // per minute
53
71
  // A global cap in addition to the per-IP one: the per-IP key is derived from
@@ -172,11 +190,17 @@ export function authGuard(
172
190
  mode: AuthMode,
173
191
  auth: AuthRuntime,
174
192
  db: Database
175
- ): MiddlewareHandler {
193
+ ): MiddlewareHandler<AppEnv> {
176
194
  switch (mode) {
177
195
  case "tailscale":
178
196
  return async (c, next) => {
197
+ prunePrincipalsIfDue(db, Date.now());
179
198
  if (isTailscaleAllowed(c, auth.trustProxy, auth.trustProxyHops)) {
199
+ const ip = clientIp(c, auth.trustProxy, auth.trustProxyHops);
200
+ c.set(
201
+ "principal",
202
+ resolveAmbientPrincipal(db, "tailscale", ip, sanitizeAmbientLabel(ip))
203
+ );
180
204
  await next();
181
205
  } else {
182
206
  return c.json({ error: "VPN access required" }, 403);
@@ -184,7 +208,18 @@ export function authGuard(
184
208
  };
185
209
  case "proxy":
186
210
  return async (c, next) => {
187
- if (hasProxyAuth(c, auth)) {
211
+ prunePrincipalsIfDue(db, Date.now());
212
+ const proxyIdentity = resolveProxyIdentity(c, auth);
213
+ if (proxyIdentity) {
214
+ c.set(
215
+ "principal",
216
+ resolveAmbientPrincipal(
217
+ db,
218
+ "proxy",
219
+ proxyIdentity.identity,
220
+ proxyIdentity.label
221
+ )
222
+ );
188
223
  await next();
189
224
  } else {
190
225
  return c.json(
@@ -195,7 +230,10 @@ export function authGuard(
195
230
  };
196
231
  case "password":
197
232
  return async (c, next) => {
198
- if (await hasValidSession(c, auth, db)) {
233
+ prunePrincipalsIfDue(db, Date.now());
234
+ const principal = await resolveCookiePrincipal(c, auth, db);
235
+ if (principal) {
236
+ c.set("principal", principal);
199
237
  await next();
200
238
  } else {
201
239
  return c.json(
@@ -205,7 +243,17 @@ export function authGuard(
205
243
  }
206
244
  };
207
245
  case "none":
208
- return async (_c, next) => {
246
+ return async (c, next) => {
247
+ prunePrincipalsIfDue(db, Date.now());
248
+ c.set(
249
+ "principal",
250
+ resolveAmbientPrincipal(
251
+ db,
252
+ "none",
253
+ "No authentication",
254
+ "No authentication"
255
+ )
256
+ );
209
257
  await next();
210
258
  };
211
259
  }
@@ -217,22 +265,47 @@ export function authGuard(
217
265
  * automatically same-origin) is the credential, and no header-modifying
218
266
  * middleware may sit on the WS route (immutable-header errors). Mirrors
219
267
  * {@link authGuard} without emitting a response.
268
+ *
269
+ * @returns The resolved principal for an authorized upgrade, otherwise null.
220
270
  */
221
271
  export async function isWsAuthorized(
222
- c: Context,
272
+ c: Context<AppEnv>,
223
273
  mode: AuthMode,
224
274
  auth: AuthRuntime,
225
275
  db: Database
226
- ): Promise<boolean> {
276
+ ): Promise<Principal | null> {
277
+ prunePrincipalsIfDue(db, Date.now());
227
278
  switch (mode) {
228
279
  case "none":
229
- return true;
230
- case "tailscale":
231
- return isTailscaleAllowed(c, auth.trustProxy, auth.trustProxyHops);
232
- case "proxy":
233
- return hasProxyAuth(c, auth);
280
+ return resolveAmbientPrincipal(
281
+ db,
282
+ "none",
283
+ "No authentication",
284
+ "No authentication"
285
+ );
286
+ case "tailscale": {
287
+ if (!isTailscaleAllowed(c, auth.trustProxy, auth.trustProxyHops)) return null;
288
+ const ip = clientIp(c, auth.trustProxy, auth.trustProxyHops);
289
+ return resolveAmbientPrincipal(
290
+ db,
291
+ "tailscale",
292
+ ip,
293
+ sanitizeAmbientLabel(ip)
294
+ );
295
+ }
296
+ case "proxy": {
297
+ const proxyIdentity = resolveProxyIdentity(c, auth);
298
+ return proxyIdentity
299
+ ? resolveAmbientPrincipal(
300
+ db,
301
+ "proxy",
302
+ proxyIdentity.identity,
303
+ proxyIdentity.label
304
+ )
305
+ : null;
306
+ }
234
307
  case "password":
235
- return hasValidSession(c, auth, db);
308
+ return resolveCookiePrincipal(c, auth, db);
236
309
  }
237
310
  }
238
311
 
@@ -246,11 +319,21 @@ export async function isWsAuthorized(
246
319
  export async function issueSessionCookie(
247
320
  c: Context,
248
321
  auth: AuthRuntime,
249
- db: Database
322
+ db: Database,
323
+ principalId: string
250
324
  ): Promise<void> {
251
325
  const secret = auth.cookieSecret ?? "";
252
- const epoch = sessionsEpoch(db);
253
- await setSignedCookie(c, COOKIE_NAME, `${Date.now()}.${epoch}`, secret, {
326
+ const principal = resolvePrincipal(db, principalId);
327
+ const now = Date.now();
328
+ if (
329
+ !principal ||
330
+ !isCookieBearingPrincipal(principal) ||
331
+ !isUsablePrincipal(principal, now)
332
+ ) {
333
+ throw new Error(`Cannot issue a session cookie for unusable principal ${principalId}`);
334
+ }
335
+ const maxAge = Math.floor((principal.expiresAt - now) / 1_000);
336
+ await setSignedCookie(c, COOKIE_NAME, principal.id, secret, {
254
337
  httpOnly: true,
255
338
  sameSite: "Strict",
256
339
  // Always Secure — every real deployment serves over HTTPS, and we do not
@@ -258,87 +341,150 @@ export async function issueSessionCookie(
258
341
  // over plaintext. (Local password testing must use https/localhost.)
259
342
  secure: true,
260
343
  path: "/",
261
- maxAge: SESSION_TTL_SECONDS,
344
+ maxAge,
262
345
  });
263
346
  }
264
347
 
265
- async function hasValidSession(
348
+ /** Turn the client-controlled User-Agent into a short, log-safe display hint. */
349
+ export function sanitizeLoginLabel(userAgent: unknown): string {
350
+ if (typeof userAgent !== "string") return LOGIN_LABEL_FALLBACK;
351
+ const label = userAgent
352
+ .replace(CONTROL_CHARACTERS, "")
353
+ .trim()
354
+ .slice(0, LOGIN_LABEL_MAX_LENGTH)
355
+ .trim();
356
+ // A label exists to be recognised in a device list. A string with nothing
357
+ // alphanumeric in it ("(((") is technically a User-Agent and useless as a
358
+ // hint, so it takes the fallback rather than being shown as-is.
359
+ if (!/[\p{L}\p{N}]/u.test(label)) return LOGIN_LABEL_FALLBACK;
360
+ return label;
361
+ }
362
+
363
+ type LoginPrincipalLineage =
364
+ | { authMethod: "password" }
365
+ | { authMethod: "passkey"; credentialId: string };
366
+
367
+ /**
368
+ * Create the durable owner principal for a verified login and issue its cookie.
369
+ * Returns a response only when the live-principal cap refuses the login.
370
+ */
371
+ export async function issueLoginSession(
266
372
  c: Context,
267
373
  auth: AuthRuntime,
268
- db: Database
269
- ): Promise<boolean> {
270
- const secret = auth.cookieSecret ?? "";
271
- if (!secret) return false;
272
- let value: string | false | undefined;
374
+ db: Database,
375
+ lineage: LoginPrincipalLineage
376
+ ): Promise<Response | null> {
377
+ prunePrincipals(db, Date.now());
378
+
273
379
  try {
274
- value = await getSignedCookie(c, secret, COOKIE_NAME);
275
- } catch {
276
- return false;
277
- }
278
- if (typeof value !== "string" || value.length === 0) return false;
279
-
280
- // Both fields are decimal integers. Requiring the exact shape rejects every
281
- // pre-epoch cookie instead of accidentally interpreting its timestamp as a
282
- // current session.
283
- const match = /^(\d+)\.(\d+)$/.exec(value);
284
- if (!match) return false;
285
- const issuedAt = Number(match[1]);
286
- const cookieEpoch = Number(match[2]);
287
- if (!Number.isSafeInteger(issuedAt) || !Number.isSafeInteger(cookieEpoch)) {
288
- return false;
380
+ const principal = createPrincipal(db, {
381
+ authMethod: lineage.authMethod,
382
+ label: sanitizeLoginLabel(c.req.header("user-agent")),
383
+ credentialId:
384
+ lineage.authMethod === "passkey" ? lineage.credentialId : undefined,
385
+ ttlSeconds: SESSION_TTL_SECONDS,
386
+ });
387
+ await issueSessionCookie(c, auth, db, principal.id);
388
+ } catch (err) {
389
+ if (err instanceof PrincipalCredentialNotFoundError) {
390
+ return c.json({ error: "Passkey verification failed" }, 401);
391
+ }
392
+ if (!(err instanceof PrincipalLimitError)) throw err;
393
+ // 503: valid credentials reached a server-side capacity limit. This is
394
+ // neither an authentication failure nor a request the client can repair
395
+ // except by waiting for expiry or signing out another device.
396
+ return c.json(
397
+ { error: "Session capacity reached. Sign out another device and try again." },
398
+ 503
399
+ );
289
400
  }
290
- const ageSeconds = (Date.now() - issuedAt) / 1000;
291
- if (ageSeconds < 0 || ageSeconds >= SESSION_TTL_SECONDS) return false;
292
401
 
293
- // Deliberately outside the cookie-parser catch: corrupt authoritative auth
294
- // state refuses loudly and must never degrade to epoch zero.
295
- return cookieEpoch === sessionsEpoch(db);
402
+ return null;
296
403
  }
297
404
 
298
405
  /**
299
- * Read authoritative session state. A missing row is first-run epoch zero;
300
- * any present row that is not a non-negative safe integer is corruption.
406
+ * Resolve the principal named by a valid session cookie. Signature and payload
407
+ * shape are checked before the principal store is queried.
301
408
  */
302
- function sessionsEpoch(db: Database): number {
303
- const row = db
304
- .query("SELECT value FROM settings WHERE key = ?")
305
- .get(SESSIONS_EPOCH_KEY) as { value: string } | null;
306
- if (!row) return 0;
307
-
308
- let value: unknown;
409
+ export async function resolveCookiePrincipal(
410
+ c: Context,
411
+ auth: AuthRuntime,
412
+ db: Database
413
+ ): Promise<Principal | null> {
414
+ const secret = auth.cookieSecret ?? "";
415
+ if (!secret) return null;
416
+ let value: string | false | undefined;
309
417
  try {
310
- value = JSON.parse(row.value);
418
+ value = await getSignedCookie(c, secret, COOKIE_NAME);
311
419
  } catch {
312
- throw new Error(`Corrupt ${SESSIONS_EPOCH_KEY}: expected a JSON integer`);
420
+ return null;
313
421
  }
314
- if (!Number.isSafeInteger(value) || (value as number) < 0) {
315
- throw new Error(`Corrupt ${SESSIONS_EPOCH_KEY}: expected a non-negative integer`);
422
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{22}$/.test(value)) {
423
+ return null;
316
424
  }
317
- return value as number;
318
- }
319
425
 
320
- /** Invalidate every issued session and disconnect every attached client. */
321
- export function bumpSessionsEpoch(db: Database, clients: ClientSet): number {
322
- const current = sessionsEpoch(db);
323
- if (current === Number.MAX_SAFE_INTEGER) {
324
- throw new Error(`${SESSIONS_EPOCH_KEY} cannot be advanced safely`);
426
+ // Deliberately after signature and shape verification, and outside the
427
+ // cookie-parser catch: arbitrary ids cannot drive reads, while corrupt
428
+ // authoritative state fails only the principal whose signed cookie names it.
429
+ const principal = resolvePrincipal(db, value);
430
+ const now = Date.now();
431
+ if (
432
+ !principal ||
433
+ !isCookieBearingPrincipal(principal) ||
434
+ !isUsablePrincipal(principal, now)
435
+ ) {
436
+ return null;
325
437
  }
326
- const next = current + 1;
327
- setSetting(db, SESSIONS_EPOCH_KEY, next);
328
- clients.closeAll(SESSION_REVOKED_CLOSE_CODE, SESSION_REVOKED_CLOSE_REASON);
329
- return next;
438
+ touchLastSeen(db, principal.id, now);
439
+ return principal;
440
+ }
441
+
442
+ /** Apply durable revocation to every live runtime authorization context. */
443
+ export function applyPrincipalRevocation(
444
+ revoker: Pick<WsHost, "revokePrincipals">,
445
+ principalIds: readonly string[]
446
+ ): void {
447
+ revoker.revokePrincipals(
448
+ principalIds,
449
+ SESSION_REVOKED_CLOSE_CODE,
450
+ SESSION_REVOKED_CLOSE_REASON
451
+ );
452
+ }
453
+
454
+ /**
455
+ * Revoke every cookie-bearing principal and disconnect its attached clients.
456
+ */
457
+ export function revokeAllSessions(
458
+ db: Database,
459
+ revoker: Pick<WsHost, "revokePrincipals">
460
+ ): void {
461
+ const revokedIds = revokeAllPrincipals(db, Date.now());
462
+ applyPrincipalRevocation(revoker, revokedIds);
330
463
  }
331
464
 
332
465
  // --- proxy mode helpers ---
333
466
 
334
- function hasProxyAuth(c: Context, auth: AuthRuntime): boolean {
467
+ const AMBIENT_LABEL_MAX_LENGTH = 64;
468
+
469
+ function sanitizeAmbientLabel(identity: string): string {
470
+ return identity
471
+ .replace(CONTROL_CHARACTERS, "")
472
+ .trim()
473
+ .slice(0, AMBIENT_LABEL_MAX_LENGTH);
474
+ }
475
+
476
+ function resolveProxyIdentity(
477
+ c: Context,
478
+ auth: AuthRuntime
479
+ ): { identity: string; label: string } | null {
335
480
  // The proxy-auth header is only meaningful when a trusted proxy fronts the app
336
481
  // and TRUST_PROXY says so; otherwise a client could set it directly. Gate on
337
482
  // trustProxy, consistent with tailscale-mode XFF trust. (assertAuthConfig
338
483
  // already refuses to boot proxy mode without it — this is belt-and-braces.)
339
- if (!auth.trustProxy) return false;
484
+ if (!auth.trustProxy) return null;
340
485
  const user = c.req.header(auth.proxyAuthHeader);
341
- return !!user && user.trim().length > 0;
486
+ if (!user || user.trim().length === 0) return null;
487
+ return { identity: user, label: sanitizeAmbientLabel(user) };
342
488
  }
343
489
 
344
490
  // --- login rate limiter (in-memory failure windows + verification reservations) ---
@@ -506,8 +652,8 @@ export function authRoutes(
506
652
  deps: {
507
653
  /** Authoritative session state. */
508
654
  db: Database;
509
- /** Live sockets closed whenever the global session epoch advances. */
510
- clients: ClientSet;
655
+ /** Runtime connections and queued work invalidated by principal revocation. */
656
+ revoker: Pick<WsHost, "revokePrincipals">;
511
657
  /**
512
658
  * When provided and returning true, password login is refused (the app
513
659
  * injects passkeys' passwordLoginDisabled so the shared password dies
@@ -522,8 +668,8 @@ export function authRoutes(
522
668
  /** Test-only password verifier; production uses Bun.password.verify. */
523
669
  verifyPassword?: (password: string, hash: string) => Promise<boolean>;
524
670
  }
525
- ): Hono {
526
- const app = new Hono();
671
+ ): Hono<AppEnv> {
672
+ const app = new Hono<AppEnv>();
527
673
  const { log, failures } = deps;
528
674
  const verifyPassword = deps.verifyPassword ?? Bun.password.verify;
529
675
  let warnedUntrustedForwardedFor = false;
@@ -633,7 +779,10 @@ export function authRoutes(
633
779
  return c.json({ error: "Invalid credentials" }, 401);
634
780
  }
635
781
 
636
- await issueSessionCookie(c, auth, deps.db);
782
+ const capacityResponse = await issueLoginSession(c, auth, deps.db, {
783
+ authMethod: "password",
784
+ });
785
+ if (capacityResponse) return capacityResponse;
637
786
  log?.emit({
638
787
  severityText: "INFO",
639
788
  body: "login succeeded",
@@ -643,10 +792,21 @@ export function authRoutes(
643
792
  });
644
793
 
645
794
  app.post("/auth/logout", async (c) => {
646
- if (mode === "password" && !(await hasValidSession(c, auth, deps.db))) {
647
- return c.json({ error: "Authentication required", authRequired: true }, 401);
795
+ if (mode === "password") {
796
+ const principal = await resolveCookiePrincipal(c, auth, deps.db);
797
+ if (!principal) {
798
+ return c.json({ error: "Authentication required", authRequired: true }, 401);
799
+ }
800
+ c.set("principal", principal);
801
+ if (principal.kind === "owner") {
802
+ revokeAllSessions(deps.db, deps.revoker);
803
+ } else {
804
+ applyPrincipalRevocation(
805
+ deps.revoker,
806
+ revokePrincipal(deps.db, principal.id, Date.now())
807
+ );
808
+ }
648
809
  }
649
- if (mode === "password") bumpSessionsEpoch(deps.db, deps.clients);
650
810
  deleteCookie(c, COOKIE_NAME, { path: "/" });
651
811
  return c.json({ ok: true });
652
812
  });
@@ -17,26 +17,29 @@ import type {
17
17
  import {
18
18
  type AuthMode,
19
19
  type AuthRuntime,
20
- issueSessionCookie,
20
+ issueLoginSession,
21
21
  consumeLoginToken,
22
22
  isLoginBlocked,
23
23
  recordLoginFailure,
24
24
  PASSKEY_LOGIN_RATE_LIMIT,
25
25
  acquirePasskeyVerification,
26
26
  releasePasskeyVerification,
27
- bumpSessionsEpoch,
27
+ applyPrincipalRevocation,
28
28
  } from "./auth.js";
29
29
  import { clientIp } from "./tailscale.js";
30
30
  import type { WebAuthnConfig } from "../config/env.js";
31
31
  import { readJsonBody } from "./body-limit.js";
32
32
  import { requireJson } from "./origin.js";
33
- import type { ClientSet } from "../ws/clients.js";
33
+ import type { AppEnv } from "../app-env.js";
34
+ import { revokeByCredential } from "../db/principals.js";
35
+ import type { WsHost } from "../ws/host.js";
36
+ import { requireOwner } from "./require-owner.js";
34
37
 
35
38
  /**
36
39
  * WebAuthn passkeys as an extension of `password` mode: the password bootstraps
37
40
  * the first registration (and stays as recovery), passkeys are the day-to-day
38
41
  * login. A successful assertion mints the same session cookie as password login
39
- * (issueSessionCookie), so authGuard / isWsAuthorized / TTL are untouched.
42
+ * (issueLoginSession), so authGuard / isWsAuthorized / TTL are untouched.
40
43
  *
41
44
  * Everything environmental is injected as a {@link PasskeyContext} — the
42
45
  * resolved WebAuthn identity, the auth runtime (for trust/proxy semantics and
@@ -50,8 +53,8 @@ import type { ClientSet } from "../ws/clients.js";
50
53
  /** Injected per-app dependencies for every passkey route and helper. */
51
54
  export interface PasskeyContext {
52
55
  db: Database;
53
- /** Live sockets invalidated when a credential is revoked. */
54
- clients: ClientSet;
56
+ /** Runtime authorization state invalidated when a credential is revoked. */
57
+ revoker: Pick<WsHost, "revokePrincipals">;
55
58
  /** WebAuthn identity plus ceremony-only origin and RP overrides. */
56
59
  webauthn: WebAuthnConfig;
57
60
  auth: AuthRuntime;
@@ -400,11 +403,14 @@ export function passkeyPublicRoutes(
400
403
  });
401
404
  return fail();
402
405
  }
406
+ const currentRow = credentialById(ctx.db, row.id);
407
+ if (!currentRow || currentRow.rp_id !== rp.rpID) return fail();
408
+
403
409
  ctx.db
404
410
  .prepare(
405
411
  "UPDATE passkey_credentials SET counter = ?, last_used_at = ? WHERE id = ?"
406
412
  )
407
- .run(result.authenticationInfo.newCounter, now(), row.id);
413
+ .run(result.authenticationInfo.newCounter, now(), currentRow.id);
408
414
  } finally {
409
415
  releasePasskeyVerification(ip);
410
416
  }
@@ -433,7 +439,11 @@ export function passkeyPublicRoutes(
433
439
  return fail();
434
440
  }
435
441
 
436
- await issueSessionCookie(c, ctx.auth, ctx.db);
442
+ const capacityResponse = await issueLoginSession(c, ctx.auth, ctx.db, {
443
+ authMethod: "passkey",
444
+ credentialId: row.id,
445
+ });
446
+ if (capacityResponse) return capacityResponse;
437
447
  return c.json({ ok: true });
438
448
  });
439
449
 
@@ -441,17 +451,24 @@ export function passkeyPublicRoutes(
441
451
  }
442
452
 
443
453
  /**
444
- * Session-gated passkey routes. Mount AFTER the auth guard — gating comes from
445
- * mount position, not per-route checks.
454
+ * Owner-only passkey management routes. Mount AFTER the auth guard: the
455
+ * family middleware below authorizes the resolved principal, it does not
456
+ * authenticate the request itself.
446
457
  */
447
458
  export function passkeyManagementRoutes(
448
459
  mode: AuthMode,
449
460
  ctx: PasskeyContext,
450
461
  deps: PasskeyDeps = {}
451
- ): Hono {
462
+ ): Hono<AppEnv> {
452
463
  const verifyRegistration = deps.verifyRegistrationResponse ?? realVerifyRegistration;
453
464
  const now = deps.now ?? Date.now;
454
- const app = new Hono();
465
+ const app = new Hono<AppEnv>();
466
+
467
+ app.use("/auth/passkey/*", async (c, next) => {
468
+ if (mode !== "password") return notEnabled(c);
469
+ await next();
470
+ });
471
+ app.use("/auth/passkey/*", requireOwner());
455
472
 
456
473
  app.post("/auth/passkey/register-options", async (c) => {
457
474
  if (mode !== "password") return notEnabled(c);
@@ -577,11 +594,15 @@ export function passkeyManagementRoutes(
577
594
 
578
595
  app.delete("/auth/passkey/:id", (c) => {
579
596
  if (mode !== "password") return notEnabled(c);
597
+ const credentialId = c.req.param("id");
580
598
  const result = ctx.db
581
599
  .prepare("DELETE FROM passkey_credentials WHERE id = ?")
582
- .run(c.req.param("id"));
600
+ .run(credentialId);
583
601
  if (result.changes === 0) return c.json({ error: "Unknown passkey" }, 404);
584
- bumpSessionsEpoch(ctx.db, ctx.clients);
602
+ applyPrincipalRevocation(
603
+ ctx.revoker,
604
+ revokeByCredential(ctx.db, credentialId, Date.now())
605
+ );
585
606
  return c.json({ ok: true });
586
607
  });
587
608