@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.
- 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 +34 -0
- package/dist/activity/sql.d.ts.map +1 -0
- package/dist/activity/sql.js +114 -0
- package/dist/activity/sql.js.map +1 -0
- package/dist/activity/store.d.ts +7 -0
- package/dist/activity/store.d.ts.map +1 -1
- package/dist/activity/store.js +48 -113
- 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 +122 -0
- package/src/activity/store.ts +73 -155
- 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
|
@@ -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,
|