@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
@@ -42,3 +42,13 @@ export interface BrainSyncResult {
42
42
  conflicts: number;
43
43
  message: string;
44
44
  }
45
+
46
+ /** Outcome of saving content, including a recoverable indexing failure. */
47
+ export interface BrainAddResult {
48
+ action: "created" | "appended";
49
+ path: string;
50
+ title: string;
51
+ type: string;
52
+ indexed: boolean;
53
+ indexError?: string;
54
+ }
@@ -8,6 +8,7 @@ import { unlinkSync } from "fs";
8
8
  import { ingestSpanSink } from "../activity/span-sink.js";
9
9
  import { createActivityStore } from "../activity/store.js";
10
10
  import { createUiDb } from "../db/client.js";
11
+ import { resolveSystemPrincipal } from "../db/principals.js";
11
12
  import { recordCronRun } from "./scheduler.js";
12
13
 
13
14
  /** Tail of stderr retained for the cron_runs error row. */
@@ -70,7 +71,7 @@ function messageOf(error: unknown): string {
70
71
  }
71
72
 
72
73
  function writeLine(sink: TextSink, text: string): void {
73
- sink.write(`${text}\n`);
74
+ try { sink.write(`${text}\n`); } catch { /* Logging cannot stop the job. */ }
74
75
  }
75
76
 
76
77
  /** Forward a stream verbatim while exposing decoded chunks for tail capture. */
@@ -80,11 +81,17 @@ export async function teeStream(
80
81
  onText: (text: string) => void
81
82
  ): Promise<void> {
82
83
  const decoder = new TextDecoder();
83
- for await (const chunk of stream) {
84
- const text = decoder.decode(chunk, { stream: true });
85
- sink.write(text);
84
+ let forwarding = true;
85
+ const forward = async (text: string) => {
86
86
  onText(text);
87
+ if (!forwarding) return;
88
+ try { await sink.write(text); } catch { forwarding = false; }
89
+ };
90
+ for await (const chunk of stream) {
91
+ await forward(decoder.decode(chunk, { stream: true }));
87
92
  }
93
+ const trailing = decoder.decode();
94
+ if (trailing) await forward(trailing);
88
95
  }
89
96
 
90
97
  /** Keep the last `cap` characters, never the head. */
@@ -99,10 +106,16 @@ async function startRecord(
99
106
  dbPath: string,
100
107
  stderr: TextSink
101
108
  ): Promise<RunRecorder | null> {
109
+ let db: ReturnType<typeof createUiDb> | undefined;
102
110
  try {
103
- const db = createUiDb(dbPath);
111
+ db = createUiDb(dbPath);
112
+ const openedDb = db;
104
113
  const record = recordCronRun(db, name);
105
114
  const store = createActivityStore(db, { writer: `cron:${process.pid}` });
115
+ const principal = resolveSystemPrincipal(db, {
116
+ identity: "scheduled-jobs",
117
+ label: "Scheduled jobs",
118
+ });
106
119
  const runId = `cron-${name}-${Date.now()}`;
107
120
  const rootSpanId = `${runId}:root`;
108
121
  store.startSpan({
@@ -112,6 +125,7 @@ async function startRecord(
112
125
  kind: "cron",
113
126
  origin: "cron",
114
127
  jobName: name,
128
+ principalId: principal.id,
115
129
  });
116
130
 
117
131
  let sinkOffset = 0;
@@ -168,10 +182,11 @@ async function startRecord(
168
182
  },
169
183
  close() {
170
184
  clearInterval(heartbeat);
171
- db.close();
185
+ openedDb.close();
172
186
  },
173
187
  };
174
188
  } catch (error) {
189
+ try { db?.close(); } catch { /* Preserve fail-open tracking. */ }
175
190
  writeLine(
176
191
  stderr,
177
192
  `[cron-run] tracking unavailable (job runs anyway): ${messageOf(error)}`
@@ -191,13 +206,12 @@ export async function runJob(
191
206
  dependencies.sinkPath ??
192
207
  `/tmp/brain-activity-sink-${process.pid}-${Date.now()}.jsonl`;
193
208
  const beginRecord = dependencies.startRecord ?? startRecord;
194
- const recorder = await beginRecord(
195
- options.jobName,
196
- sinkPath,
197
- options.command,
198
- options.dbPath,
199
- stderr
200
- );
209
+ let recorder: RunRecorder | null = null;
210
+ try {
211
+ recorder = await beginRecord(options.jobName, sinkPath, options.command, options.dbPath, stderr);
212
+ } catch (error) {
213
+ writeLine(stderr, `[cron-run] tracking unavailable (job runs anyway): ${messageOf(error)}`);
214
+ }
201
215
 
202
216
  let exitCode: number;
203
217
  let errorMessage: string | undefined;
@@ -213,7 +227,7 @@ export async function runJob(
213
227
  });
214
228
 
215
229
  let stderrTail = "";
216
- await Promise.all([
230
+ const outcomes = await Promise.allSettled([
217
231
  teeStream(proc.stdout, stdout, (text) => {
218
232
  outputTail = appendTail(outputTail, text, OUTPUT_TAIL_CHARS);
219
233
  }),
@@ -221,9 +235,16 @@ export async function runJob(
221
235
  stderrTail = appendTail(stderrTail, text, STDERR_TAIL_CHARS);
222
236
  outputTail = appendTail(outputTail, text, OUTPUT_TAIL_CHARS);
223
237
  }),
238
+ proc.exited,
224
239
  ]);
225
-
226
- exitCode = await proc.exited;
240
+ for (const outcome of outcomes.slice(0, 2)) {
241
+ if (outcome.status === "rejected") {
242
+ writeLine(stderr, `[cron-run] output capture failed: ${messageOf(outcome.reason)}`);
243
+ }
244
+ }
245
+ const child = outcomes[2]!;
246
+ if (child.status === "rejected") throw child.reason;
247
+ exitCode = child.value;
227
248
  if (exitCode !== 0) {
228
249
  const cause = proc.signalCode ? `killed by ${proc.signalCode}` : `exit code ${exitCode}`;
229
250
  const trimmedTail = stderrTail.trim();
@@ -238,9 +259,12 @@ export async function runJob(
238
259
  if (recorder) {
239
260
  try {
240
261
  recorder.finish(errorMessage, outputTail.trim(), exitCode);
241
- recorder.close();
242
262
  } catch (error) {
243
263
  writeLine(stderr, `[cron-run] failed to record run outcome: ${messageOf(error)}`);
264
+ } finally {
265
+ try { recorder.close(); } catch (error) {
266
+ writeLine(stderr, `[cron-run] failed to close tracking: ${messageOf(error)}`);
267
+ }
244
268
  }
245
269
  }
246
270
 
@@ -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,