@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.
- package/README.md +28 -0
- package/dist/activity/notify.d.ts.map +1 -1
- package/dist/activity/notify.js +18 -8
- package/dist/activity/notify.js.map +1 -1
- package/dist/activity/push-sender.d.ts +7 -3
- package/dist/activity/push-sender.d.ts.map +1 -1
- package/dist/activity/push-sender.js +45 -11
- package/dist/activity/push-sender.js.map +1 -1
- package/dist/activity/query.d.ts.map +1 -1
- package/dist/activity/query.js +8 -0
- package/dist/activity/query.js.map +1 -1
- package/dist/activity/recorder.d.ts +11 -1
- package/dist/activity/recorder.d.ts.map +1 -1
- package/dist/activity/recorder.js +52 -2
- package/dist/activity/recorder.js.map +1 -1
- package/dist/activity/runtime.d.ts.map +1 -1
- package/dist/activity/runtime.js +2 -5
- package/dist/activity/runtime.js.map +1 -1
- package/dist/activity/sql.d.ts +3 -3
- package/dist/activity/sql.d.ts.map +1 -1
- package/dist/activity/sql.js +7 -5
- package/dist/activity/sql.js.map +1 -1
- package/dist/activity/store.d.ts +7 -0
- package/dist/activity/store.d.ts.map +1 -1
- package/dist/activity/store.js +15 -3
- package/dist/activity/store.js.map +1 -1
- package/dist/activity/stream.d.ts +3 -1
- package/dist/activity/stream.d.ts.map +1 -1
- package/dist/activity/stream.js +32 -17
- package/dist/activity/stream.js.map +1 -1
- package/dist/app-env.d.ts +8 -0
- package/dist/app-env.d.ts.map +1 -0
- package/dist/app-env.js +2 -0
- package/dist/app-env.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +28 -3
- package/dist/app.js.map +1 -1
- package/dist/brain/client.d.ts +4 -2
- package/dist/brain/client.d.ts.map +1 -1
- package/dist/brain/client.js +60 -20
- package/dist/brain/client.js.map +1 -1
- package/dist/brain/types.d.ts +9 -0
- package/dist/brain/types.d.ts.map +1 -1
- package/dist/cron/run-job.d.ts.map +1 -1
- package/dist/cron/run-job.js +60 -10
- package/dist/cron/run-job.js.map +1 -1
- package/dist/db/principals.d.ts +72 -0
- package/dist/db/principals.d.ts.map +1 -0
- package/dist/db/principals.js +294 -0
- package/dist/db/principals.js.map +1 -0
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +6 -1
- package/dist/files/walker.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +37 -9
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +148 -74
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +9 -7
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +25 -7
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/middleware/principals.d.ts +18 -0
- package/dist/middleware/principals.d.ts.map +1 -0
- package/dist/middleware/principals.js +138 -0
- package/dist/middleware/principals.js.map +1 -0
- package/dist/middleware/require-owner.d.ts +5 -0
- package/dist/middleware/require-owner.d.ts.map +1 -0
- package/dist/middleware/require-owner.js +10 -0
- package/dist/middleware/require-owner.js.map +1 -0
- package/dist/observability/loggers.js +1 -1
- package/dist/observability/loggers.js.map +1 -1
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/activity.js +3 -0
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +102 -63
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +7 -2
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/push.d.ts +61 -2
- package/dist/routes/push.d.ts.map +1 -1
- package/dist/routes/push.js +8 -3
- package/dist/routes/push.js.map +1 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +30 -4
- package/dist/routes/sessions.js.map +1 -1
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +11 -4
- package/dist/share/staging.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +6 -2
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +9 -2
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +44 -6
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts +7 -4
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +83 -12
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts +12 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +46 -6
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/history.d.ts.map +1 -1
- package/dist/ws/history.js +7 -5
- package/dist/ws/history.js.map +1 -1
- package/dist/ws/host.d.ts +22 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +39 -1
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts +7 -7
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +208 -52
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/shrink.d.ts +3 -1
- package/dist/ws/shrink.d.ts.map +1 -1
- package/dist/ws/shrink.js +46 -16
- package/dist/ws/shrink.js.map +1 -1
- package/dist/ws/turns.d.ts +58 -3
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +101 -2
- package/dist/ws/turns.js.map +1 -1
- package/migrations/011_principals.sql +13 -0
- package/migrations/012_activity_principals.sql +12 -0
- package/migrations/013_push_subscription_principals.sql +7 -0
- package/migrations/014_invalidate_legacy_sessions.sql +14 -0
- package/package.json +3 -3
- package/src/activity/notify.ts +16 -7
- package/src/activity/push-sender.ts +79 -16
- package/src/activity/query.ts +8 -0
- package/src/activity/recorder.ts +68 -3
- package/src/activity/runtime.ts +2 -5
- package/src/activity/sql.ts +7 -5
- package/src/activity/store.ts +32 -1
- package/src/activity/stream.ts +37 -17
- package/src/app-env.ts +8 -0
- package/src/app.ts +33 -4
- package/src/brain/client.ts +61 -20
- package/src/brain/types.ts +10 -0
- package/src/cron/run-job.ts +41 -17
- package/src/db/principals.ts +452 -0
- package/src/files/walker.ts +5 -1
- package/src/index.ts +6 -1
- package/src/middleware/auth.ts +241 -81
- package/src/middleware/passkeys.ts +35 -14
- package/src/middleware/principals.ts +189 -0
- package/src/middleware/require-owner.ts +13 -0
- package/src/observability/loggers.ts +1 -1
- package/src/routes/activity.ts +3 -0
- package/src/routes/brain.ts +107 -70
- package/src/routes/files.ts +10 -5
- package/src/routes/push.ts +11 -5
- package/src/routes/sessions.ts +45 -20
- package/src/share/staging.ts +11 -4
- package/src/ws/bridge.ts +14 -2
- package/src/ws/clients.ts +52 -7
- package/src/ws/connection.ts +98 -20
- package/src/ws/dispatch.ts +66 -6
- package/src/ws/history.ts +7 -5
- package/src/ws/host.ts +59 -1
- package/src/ws/run-session.ts +247 -74
- package/src/ws/shrink.ts +48 -14
- package/src/ws/turns.ts +136 -5
package/src/brain/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/cron/run-job.ts
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
sinkPath,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/files/walker.ts
CHANGED
|
@@ -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,
|