@schlessera/brain-ui-server 0.34.0 → 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 +34 -0
  20. package/dist/activity/sql.d.ts.map +1 -0
  21. package/dist/activity/sql.js +114 -0
  22. package/dist/activity/sql.js.map +1 -0
  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 +48 -113
  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 +122 -0
  140. package/src/activity/store.ts +73 -155
  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
@@ -0,0 +1,452 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { Database } from "bun:sqlite";
3
+
4
+ export const MAX_LIVE_PRINCIPALS = 100;
5
+ export const PRINCIPAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
6
+ export const PRINCIPAL_PRUNE_INTERVAL_MS = 60 * 60 * 1_000;
7
+
8
+ export const LAST_SEEN_WRITE_INTERVAL_MS = 60_000;
9
+ const MAX_LABEL_LENGTH = 64;
10
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/u;
11
+ const lastPrincipalPruneByDb = new WeakMap<Database, number>();
12
+
13
+ export type PrincipalKind = "owner" | "agent" | "ambient" | "system";
14
+ export type PrincipalAuthMethod =
15
+ | "password"
16
+ | "passkey"
17
+ | "delegated"
18
+ | "ambient";
19
+
20
+ export type AmbientPrincipalSource = "none" | "proxy" | "tailscale";
21
+
22
+ export interface Principal {
23
+ id: string;
24
+ kind: PrincipalKind;
25
+ authMethod: PrincipalAuthMethod;
26
+ label: string;
27
+ credentialId: string | null;
28
+ createdBy: string | null;
29
+ createdAt: number;
30
+ expiresAt: number;
31
+ lastSeenAt: number | null;
32
+ revokedAt: number | null;
33
+ }
34
+
35
+ export interface CreatePrincipalInput {
36
+ authMethod: Exclude<PrincipalAuthMethod, "ambient">;
37
+ label: string;
38
+ credentialId?: string;
39
+ createdBy?: string;
40
+ ttlSeconds: number;
41
+ }
42
+
43
+ export interface ResolveSystemPrincipalInput {
44
+ identity: string;
45
+ label: string;
46
+ }
47
+
48
+ export class PrincipalLimitError extends Error {
49
+ constructor() {
50
+ super(`live principal limit of ${MAX_LIVE_PRINCIPALS} reached`);
51
+ this.name = "PrincipalLimitError";
52
+ }
53
+ }
54
+
55
+ export class PrincipalCredentialNotFoundError extends Error {
56
+ constructor(credentialId: string) {
57
+ super(`passkey credential ${credentialId} no longer exists`);
58
+ this.name = "PrincipalCredentialNotFoundError";
59
+ }
60
+ }
61
+
62
+ export class PrincipalCreatorNotUsableError extends Error {
63
+ constructor(createdBy: string | undefined) {
64
+ super(`principal creator ${createdBy ?? "(missing)"} is not a usable owner`);
65
+ this.name = "PrincipalCreatorNotUsableError";
66
+ }
67
+ }
68
+
69
+ interface PrincipalDbRow {
70
+ id: string;
71
+ kind: PrincipalKind;
72
+ auth_method: PrincipalAuthMethod;
73
+ label: string;
74
+ credential_id: string | null;
75
+ created_by: string | null;
76
+ created_at: number;
77
+ expires_at: number;
78
+ last_seen_at: number | null;
79
+ revoked_at: number | null;
80
+ }
81
+
82
+ const PRINCIPAL_COLUMNS = `id, kind, auth_method, label, credential_id,
83
+ created_by, created_at, expires_at, last_seen_at, revoked_at`;
84
+
85
+ function rowToPrincipal(row: PrincipalDbRow): Principal {
86
+ return {
87
+ id: row.id,
88
+ kind: row.kind,
89
+ authMethod: row.auth_method,
90
+ label: row.label,
91
+ credentialId: row.credential_id,
92
+ createdBy: row.created_by,
93
+ createdAt: row.created_at,
94
+ expiresAt: row.expires_at,
95
+ lastSeenAt: row.last_seen_at,
96
+ revokedAt: row.revoked_at,
97
+ };
98
+ }
99
+
100
+ function validateLabel(label: string): void {
101
+ if (typeof label !== "string") throw new TypeError("principal label must be a string");
102
+ if (label.length > MAX_LABEL_LENGTH) {
103
+ throw new RangeError(`principal label must be at most ${MAX_LABEL_LENGTH} characters`);
104
+ }
105
+ if (CONTROL_CHARACTER.test(label)) {
106
+ throw new RangeError("principal label must not contain control characters");
107
+ }
108
+ }
109
+
110
+ function expiryFromTtl(now: number, ttlSeconds: number): number {
111
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0) {
112
+ throw new RangeError("principal ttlSeconds must be a positive safe integer");
113
+ }
114
+ const expiresAt = now + ttlSeconds * 1_000;
115
+ if (!Number.isSafeInteger(expiresAt)) {
116
+ throw new RangeError("principal expiry must be a safe integer");
117
+ }
118
+ return expiresAt;
119
+ }
120
+
121
+ function generatePrincipalId(): string {
122
+ return randomBytes(16).toString("base64url");
123
+ }
124
+
125
+ function ambientPrincipalId(
126
+ source: AmbientPrincipalSource,
127
+ identity: string
128
+ ): string {
129
+ return createHash("sha256")
130
+ .update("brain-ui:ambient-principal:v1\0")
131
+ .update(source)
132
+ .update("\0")
133
+ .update(identity)
134
+ .digest()
135
+ .subarray(0, 16)
136
+ .toString("base64url");
137
+ }
138
+
139
+ function systemPrincipalId(identity: string): string {
140
+ return createHash("sha256")
141
+ .update("brain-ui:system-principal:v1\0")
142
+ .update(identity)
143
+ .digest()
144
+ .subarray(0, 16)
145
+ .toString("base64url");
146
+ }
147
+
148
+ function kindFromAuthMethod(
149
+ authMethod: CreatePrincipalInput["authMethod"]
150
+ ): "owner" | "agent" {
151
+ switch (authMethod) {
152
+ case "password":
153
+ case "passkey":
154
+ return "owner";
155
+ case "delegated":
156
+ return "agent";
157
+ default:
158
+ throw new TypeError(`unsupported principal auth method: ${String(authMethod)}`);
159
+ }
160
+ }
161
+
162
+ export function createPrincipal(db: Database, input: CreatePrincipalInput): Principal {
163
+ validateLabel(input.label);
164
+
165
+ return db.transaction(() => {
166
+ const now = Date.now();
167
+ const expiresAt = expiryFromTtl(now, input.ttlSeconds);
168
+ if (input.authMethod === "delegated") {
169
+ // Request authentication may precede an awaited body read. Re-check the
170
+ // creator only after taking the write lock, in the transaction that
171
+ // inserts the delegated row, so revocation cannot race this authority.
172
+ const creator = input.createdBy
173
+ ? resolvePrincipal(db, input.createdBy)
174
+ : null;
175
+ if (
176
+ !creator ||
177
+ creator.kind !== "owner" ||
178
+ !isUsablePrincipal(creator, now)
179
+ ) {
180
+ throw new PrincipalCreatorNotUsableError(input.createdBy);
181
+ }
182
+ }
183
+ if (
184
+ input.credentialId !== undefined &&
185
+ !db.prepare("SELECT 1 FROM passkey_credentials WHERE id = ?").get(input.credentialId)
186
+ ) {
187
+ throw new PrincipalCredentialNotFoundError(input.credentialId);
188
+ }
189
+ if (countLivePrincipals(db, now) >= MAX_LIVE_PRINCIPALS) {
190
+ throw new PrincipalLimitError();
191
+ }
192
+
193
+ let id = generatePrincipalId();
194
+ const idExists = db.prepare("SELECT 1 FROM principals WHERE id = ?");
195
+ while (idExists.get(id)) id = generatePrincipalId();
196
+
197
+ db.prepare(
198
+ `INSERT INTO principals
199
+ (id, kind, auth_method, label, credential_id, created_by, created_at, expires_at)
200
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
201
+ ).run(
202
+ id,
203
+ kindFromAuthMethod(input.authMethod),
204
+ input.authMethod,
205
+ input.label,
206
+ input.credentialId ?? null,
207
+ input.createdBy ?? null,
208
+ now,
209
+ expiresAt
210
+ );
211
+
212
+ return resolvePrincipal(db, id)!;
213
+ }).immediate();
214
+ }
215
+
216
+ /**
217
+ * Resolve the durable attribution row for an ambient authorization identity.
218
+ * The id is deterministic per mode and full identity so repeated requests —
219
+ * including concurrent first requests — cannot grow one row per authorization
220
+ * check, while bounded display-label collisions remain distinct.
221
+ */
222
+ export function resolveAmbientPrincipal(
223
+ db: Database,
224
+ source: AmbientPrincipalSource,
225
+ identity: string,
226
+ label: string
227
+ ): Principal {
228
+ validateLabel(label);
229
+ const id = ambientPrincipalId(source, identity);
230
+ const existing = resolvePrincipal(db, id);
231
+ if (existing) return resolveStoredIdentity(db, existing, label, "ambient");
232
+
233
+ return db.transaction(() => {
234
+ let principal = resolvePrincipal(db, id);
235
+ if (!principal) {
236
+ const now = Date.now();
237
+ db.prepare(
238
+ `INSERT INTO principals
239
+ (id, kind, auth_method, label, credential_id, created_by,
240
+ created_at, expires_at, last_seen_at)
241
+ VALUES (?, 'ambient', 'ambient', ?, NULL, NULL, ?, ?, ?)`
242
+ ).run(id, label, now, Number.MAX_SAFE_INTEGER, now);
243
+ principal = resolvePrincipal(db, id)!;
244
+ }
245
+
246
+ return resolveStoredIdentity(db, principal, label, "ambient");
247
+ }).immediate();
248
+ }
249
+
250
+ /** Resolve the durable attribution row for a non-cookie system identity. */
251
+ export function resolveSystemPrincipal(
252
+ db: Database,
253
+ input: ResolveSystemPrincipalInput
254
+ ): Principal {
255
+ validateLabel(input.label);
256
+ const id = systemPrincipalId(input.identity);
257
+ const existing = resolvePrincipal(db, id);
258
+ if (existing) return resolveStoredIdentity(db, existing, input.label, "system");
259
+
260
+ return db.transaction(() => {
261
+ let principal = resolvePrincipal(db, id);
262
+ if (!principal) {
263
+ const now = Date.now();
264
+ db.prepare(
265
+ `INSERT INTO principals
266
+ (id, kind, auth_method, label, credential_id, created_by,
267
+ created_at, expires_at)
268
+ VALUES (?, 'system', 'ambient', ?, NULL, NULL, ?, ?)`
269
+ ).run(id, input.label, now, Number.MAX_SAFE_INTEGER);
270
+ principal = resolvePrincipal(db, id)!;
271
+ }
272
+ return resolveStoredIdentity(db, principal, input.label, "system");
273
+ }).immediate();
274
+ }
275
+
276
+ function resolveStoredIdentity(
277
+ db: Database,
278
+ principal: Principal,
279
+ label: string,
280
+ kind: "ambient" | "system"
281
+ ): Principal {
282
+ if (
283
+ principal.kind !== kind ||
284
+ principal.authMethod !== "ambient" ||
285
+ !isUsablePrincipal(principal, Date.now())
286
+ ) {
287
+ throw new Error(`Corrupt ${kind} principal ${principal.id}`);
288
+ }
289
+ if (principal.label !== label) {
290
+ db.prepare("UPDATE principals SET label = ? WHERE id = ?").run(
291
+ label,
292
+ principal.id
293
+ );
294
+ principal = { ...principal, label };
295
+ }
296
+ if (kind === "ambient") {
297
+ // The UPDATE's WHERE throttles the write, but issuing it at all starts an
298
+ // implicit write transaction — on every request, in three of four auth
299
+ // modes. Decide from the row already in hand; the SQL predicate stays as
300
+ // the concurrency guard for the writes that do happen.
301
+ const now = Date.now();
302
+ if (isLastSeenStale(principal.lastSeenAt, now)) {
303
+ touchLastSeen(db, principal.id, now);
304
+ principal = { ...principal, lastSeenAt: now };
305
+ }
306
+ }
307
+ return principal;
308
+ }
309
+
310
+ export function resolvePrincipal(db: Database, id: string): Principal | null {
311
+ const row = db
312
+ .prepare(`SELECT ${PRINCIPAL_COLUMNS} FROM principals WHERE id = ?`)
313
+ .get(id) as PrincipalDbRow | null;
314
+ return row ? rowToPrincipal(row) : null;
315
+ }
316
+
317
+ /** List currently usable cookie-bearing principals, newest first. */
318
+ export function listLivePrincipals(db: Database, now: number): Principal[] {
319
+ return (
320
+ db
321
+ .prepare(
322
+ `SELECT ${PRINCIPAL_COLUMNS} FROM principals
323
+ WHERE kind IN ('owner', 'agent')
324
+ AND revoked_at IS NULL AND expires_at > ?
325
+ ORDER BY created_at DESC, id ASC`
326
+ )
327
+ .all(now) as PrincipalDbRow[]
328
+ ).map(rowToPrincipal);
329
+ }
330
+
331
+ export function isUsablePrincipal(row: Principal, now: number): boolean {
332
+ assertPrincipalTimestamp(row, "created_at", row.createdAt, false);
333
+ assertPrincipalTimestamp(row, "expires_at", row.expiresAt, false);
334
+ assertPrincipalTimestamp(row, "last_seen_at", row.lastSeenAt, true);
335
+ assertPrincipalTimestamp(row, "revoked_at", row.revokedAt, true);
336
+ return row.revokedAt === null && now < row.expiresAt;
337
+ }
338
+
339
+ /** Whether this principal kind may be carried by an authenticated cookie. */
340
+ export function isCookieBearingPrincipal(
341
+ principal: Principal
342
+ ): principal is Principal & { kind: "owner" | "agent" } {
343
+ return principal.kind === "owner" || principal.kind === "agent";
344
+ }
345
+
346
+ function assertPrincipalTimestamp(
347
+ row: Principal,
348
+ column: string,
349
+ value: number | null,
350
+ nullable: boolean
351
+ ): void {
352
+ if ((nullable && value === null) || Number.isSafeInteger(value)) return;
353
+ throw new Error(`Corrupt principal ${row.id}: ${column} must be an integer`);
354
+ }
355
+
356
+ /** Whether a stored last_seen_at is old enough to be worth a write. */
357
+ export function isLastSeenStale(lastSeenAt: number | null, now: number): boolean {
358
+ return lastSeenAt === null || lastSeenAt < now - LAST_SEEN_WRITE_INTERVAL_MS;
359
+ }
360
+
361
+ export function touchLastSeen(db: Database, id: string, now: number): void {
362
+ db.prepare(
363
+ `UPDATE principals SET last_seen_at = ?
364
+ WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)`
365
+ ).run(now, id, now - LAST_SEEN_WRITE_INTERVAL_MS);
366
+ }
367
+
368
+ export function revokePrincipal(db: Database, id: string, now: number): string[] {
369
+ return idsFromRows(
370
+ db
371
+ .prepare(
372
+ `UPDATE principals SET revoked_at = ?
373
+ WHERE id = ? AND kind IN ('owner', 'agent')
374
+ AND revoked_at IS NULL RETURNING id`
375
+ )
376
+ .all(now, id) as Array<{ id: string }>
377
+ );
378
+ }
379
+
380
+ export function revokeAllPrincipals(db: Database, now: number): string[] {
381
+ return idsFromRows(
382
+ db
383
+ .prepare(
384
+ `UPDATE principals SET revoked_at = ?
385
+ WHERE kind IN ('owner', 'agent')
386
+ AND revoked_at IS NULL RETURNING id`
387
+ )
388
+ .all(now) as Array<{ id: string }>
389
+ );
390
+ }
391
+
392
+ export function revokeByCredential(
393
+ db: Database,
394
+ credentialId: string,
395
+ now: number
396
+ ): string[] {
397
+ return idsFromRows(
398
+ db
399
+ .prepare(
400
+ `UPDATE principals SET revoked_at = ?
401
+ WHERE credential_id = ? AND revoked_at IS NULL RETURNING id`
402
+ )
403
+ .all(now, credentialId) as Array<{ id: string }>
404
+ );
405
+ }
406
+
407
+ function idsFromRows(rows: Array<{ id: string }>): string[] {
408
+ return rows.map(({ id }) => id);
409
+ }
410
+
411
+ export function prunePrincipals(db: Database, now: number): void {
412
+ // Thirty days preserves a useful audit/debug window after a principal stops
413
+ // being usable while still placing a fixed bound on terminal-row growth.
414
+ const cutoff = now - PRINCIPAL_RETENTION_MS;
415
+ db.prepare(
416
+ `DELETE FROM principals
417
+ WHERE (kind = 'ambient' AND COALESCE(last_seen_at, created_at) < ?)
418
+ OR (kind <> 'ambient' AND (
419
+ (revoked_at IS NOT NULL AND revoked_at < ?)
420
+ OR expires_at < ?
421
+ ))`
422
+ ).run(cutoff, cutoff, cutoff);
423
+ }
424
+
425
+ /**
426
+ * Run principal retention at most once per database per process interval.
427
+ * Authentication paths call this cheaply; only the due call reaches SQLite.
428
+ */
429
+ export function prunePrincipalsIfDue(db: Database, now: number): boolean {
430
+ const lastPrunedAt = lastPrincipalPruneByDb.get(db);
431
+ if (
432
+ lastPrunedAt !== undefined &&
433
+ now >= lastPrunedAt &&
434
+ now - lastPrunedAt < PRINCIPAL_PRUNE_INTERVAL_MS
435
+ ) {
436
+ return false;
437
+ }
438
+ prunePrincipals(db, now);
439
+ lastPrincipalPruneByDb.set(db, now);
440
+ return true;
441
+ }
442
+
443
+ export function countLivePrincipals(db: Database, now: number): number {
444
+ const row = db
445
+ .prepare(
446
+ `SELECT COUNT(*) AS count FROM principals
447
+ WHERE kind IN ('owner', 'agent')
448
+ AND revoked_at IS NULL AND expires_at > ?`
449
+ )
450
+ .get(now) as { count: number };
451
+ return row.count;
452
+ }
@@ -383,12 +383,16 @@ export async function resolveForRaw(rel: string, root: string): Promise<{ abs: s
383
383
  */
384
384
  export async function buildWikilinkMap(root: string): Promise<Record<string, string>> {
385
385
  const matcher = await loadIgnore(root);
386
- const out: Record<string, string> = {};
386
+ const out: Record<string, string> = Object.create(null);
387
+ const visited = new Set<string>();
387
388
 
388
389
  async function walk(rel: string): Promise<void> {
389
390
  const abs = await safeResolve(rel, root);
390
391
  let dirents;
391
392
  try {
393
+ const canonical = await realpath(abs);
394
+ if (visited.has(canonical)) return;
395
+ visited.add(canonical);
392
396
  dirents = await readdir(abs, { withFileTypes: true });
393
397
  } catch {
394
398
  return;
package/src/index.ts CHANGED
@@ -41,13 +41,17 @@ export {
41
41
  } from "./agent/backend.js";
42
42
  export {
43
43
  authGuard,
44
- bumpSessionsEpoch,
45
44
  isWsAuthorized,
45
+ resolveCookiePrincipal,
46
46
  resolveAuthMode,
47
+ revokeAllSessions,
47
48
  type AuthMode,
48
49
  type AuthRuntime,
49
50
  } from "./middleware/auth.js";
50
51
 
52
+ export { type Principal } from "./db/principals.js";
53
+ export { type AppEnv } from "./app-env.js";
54
+
51
55
  // The app's own SQLite database (sessions, passkeys, settings).
52
56
  export { createUiDb } from "./db/client.js";
53
57
 
@@ -68,6 +72,7 @@ export {
68
72
  turnIdMatches,
69
73
  type ConnectionState,
70
74
  } from "./ws/dispatch.js";
75
+ export { type AuthorizationContext } from "./ws/turns.js";
71
76
  export { resolveTurnTarget } from "./ws/routing.js";
72
77
  export {
73
78
  createSessionCatalog,