cartethyia 2.5.2 → 2.5.3
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/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-hfCzi-Ux.js} +1 -1
- package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-BO9nKg9w.js → CliToolDetailPage-DLGqYEwI.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-DEDyKBlC.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-CfuYiQ8Z.js} +1 -1
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-bsLuZmYH.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-_UWhwziJ.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-qomXThxX.js → HealthEventsModal-Cjfc5WFS.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-DDVIfZGW.js} +1 -1
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-C-ExCy1V.js → OverviewPage-B53YYMng.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-BS9IEpvv.js → ProvidersPage-Dx31DgIO.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-Rn_qXScb.js → ProxyPage-BpnR4kXT.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BihkgQSq.js → QuotaPage-BpOQCnun.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +1 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-D6pmQ86L.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-DWvKiuSl.js} +1 -1
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-CmkUrgdj.js} +1 -1
- package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-B47uOydr.js} +1 -1
- package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Dj9AR0gY.js → entry-CkryrIea.js} +3 -3
- package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
- package/dist/dashboard/assets/{index-DCBtROoK.js → index-BydyKaDz.js} +2 -2
- package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
- package/dist/dashboard/assets/{select-DsX5QT8K.js → select-BO6h8yNv.js} +1 -1
- package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +21 -21
- package/package.json +1 -1
- package/scripts/cli/command/console-menu.ts +248 -246
- package/scripts/cli/command/tray.ts +208 -204
- package/src/config/spec.ts +26 -2
- package/src/console/backup/routes.ts +52 -1
- package/src/console/backup/service.ts +60 -4
- package/src/console/share/share-router.ts +6 -9
- package/src/observability/payload-capture.ts +366 -344
- package/src/observability/payload-store.ts +12 -2
- package/src/observability/telemetry-status.ts +28 -16
- package/src/persistence/stores/api-key-store.ts +6 -0
- package/src/persistence/stores/share-store.ts +583 -556
- package/src/persistence/stores/telemetry-store.ts +1 -1
- package/src/protocol/response/gemini.ts +18 -4
- package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
- package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
- package/src/providers/integrations/cline/cline-quota.ts +8 -11
- package/src/providers/integrations/opencode/provider.ts +532 -532
- package/src/providers/integrations/tokenharbor.ts +2 -2
- package/src/providers/integrations/trae/trae-catalog.ts +5 -3
- package/src/providers/operations/account-health-service.ts +23 -0
- package/src/providers/operations/client-versions.ts +9 -2
- package/src/providers/provider-metadata.ts +7 -1
- package/src/transport/dispatch/attempt-finalize.ts +46 -5
- package/src/transport/dispatch/proxy-request.ts +9 -0
- package/src/transport/dispatch/streaming/stream.ts +16 -2
- package/src/transport/routing/route-catalog.ts +806 -799
- package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
- package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
- package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
|
@@ -1,556 +1,583 @@
|
|
|
1
|
-
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
// Share-link persistence and atomic child-key issuance. The hash is the lookup
|
|
4
|
-
// key; the bearer token is retained encrypted so the console can re-display a
|
|
5
|
-
// stable link, and is never selected by a public lookup.
|
|
6
|
-
|
|
7
|
-
import { and, desc, eq, isNull, sql,
|
|
8
|
-
import type { CartethyiaDatabase } from "../db-handle";
|
|
9
|
-
import { lockRows } from "../dialect";
|
|
10
|
-
import { apiKeys, shareLinks, sharePopups, type ShareLinkKind, type ApiKeyModelAccessMode } from "../schema";
|
|
11
|
-
|
|
12
|
-
/** Hashes a share bearer token for storage and lookup. */
|
|
13
|
-
/**
|
|
14
|
-
* A fresh public slug for a share page.
|
|
15
|
-
*
|
|
16
|
-
* Held in the clear and looked up by index, so it must be unguessable on its
|
|
17
|
-
* own: the same 256 bits of entropy as the bearer token, base64url-encoded.
|
|
18
|
-
*/
|
|
19
|
-
export function generateShareSlug(): string {
|
|
20
|
-
return randomBytes(32).toString("base64url");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function hashShareToken(token: string): string {
|
|
24
|
-
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
readonly
|
|
43
|
-
readonly
|
|
44
|
-
readonly
|
|
45
|
-
readonly createdAt: Date;
|
|
46
|
-
readonly expiresAt: Date | null;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
readonly
|
|
55
|
-
readonly
|
|
56
|
-
readonly
|
|
57
|
-
|
|
58
|
-
readonly
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
readonly
|
|
68
|
-
readonly
|
|
69
|
-
readonly
|
|
70
|
-
|
|
71
|
-
readonly
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
readonly
|
|
81
|
-
readonly
|
|
82
|
-
readonly
|
|
83
|
-
|
|
84
|
-
readonly
|
|
85
|
-
readonly
|
|
86
|
-
readonly
|
|
87
|
-
readonly
|
|
88
|
-
readonly
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
readonly
|
|
94
|
-
readonly
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
readonly
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
| { readonly
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
export type
|
|
136
|
-
| {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
type
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return {
|
|
298
|
-
id:
|
|
299
|
-
apiKeyId:
|
|
300
|
-
kind:
|
|
301
|
-
createdAt:
|
|
302
|
-
expiresAt:
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
.
|
|
417
|
-
.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
),
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
.
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
1
|
+
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Share-link persistence and atomic child-key issuance. The hash is the lookup
|
|
4
|
+
// key; the bearer token is retained encrypted so the console can re-display a
|
|
5
|
+
// stable link, and is never selected by a public lookup.
|
|
6
|
+
|
|
7
|
+
import { and, desc, eq, isNull, or, sql, type SQL } from "drizzle-orm";
|
|
8
|
+
import type { CartethyiaDatabase } from "../db-handle";
|
|
9
|
+
import { lockRows } from "../dialect";
|
|
10
|
+
import { apiKeys, shareLinks, sharePopups, type ShareLinkKind, type ApiKeyModelAccessMode } from "../schema";
|
|
11
|
+
|
|
12
|
+
/** Hashes a share bearer token for storage and lookup. */
|
|
13
|
+
/**
|
|
14
|
+
* A fresh public slug for a share page.
|
|
15
|
+
*
|
|
16
|
+
* Held in the clear and looked up by index, so it must be unguessable on its
|
|
17
|
+
* own: the same 256 bits of entropy as the bearer token, base64url-encoded.
|
|
18
|
+
*/
|
|
19
|
+
export function generateShareSlug(): string {
|
|
20
|
+
return randomBytes(32).toString("base64url");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hashShareToken(token: string): string {
|
|
24
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The predicate matching one link's two public address forms.
|
|
29
|
+
*
|
|
30
|
+
* `slug` is held in the clear — that is what lets a restore onto a fresh
|
|
31
|
+
* deployment keep every address — while the bearer token is only ever stored
|
|
32
|
+
* as `hashShareToken(token)`. So the two columns cannot be compared against
|
|
33
|
+
* one value: a lookup built from a hash alone silently never matches the slug
|
|
34
|
+
* column, which is how a freshly generated link answered "not found".
|
|
35
|
+
*/
|
|
36
|
+
function addressMatches(address: string): SQL {
|
|
37
|
+
return or(eq(shareLinks.slug, address), eq(shareLinks.tokenHash, hashShareToken(address)))!;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Newly created share-link metadata. */
|
|
41
|
+
export interface ShareLinkRecord {
|
|
42
|
+
readonly id: string;
|
|
43
|
+
readonly apiKeyId: string;
|
|
44
|
+
readonly kind: ShareLinkKind;
|
|
45
|
+
readonly createdAt: Date;
|
|
46
|
+
readonly expiresAt: Date | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Console-facing view of one link. The caller supplies the decrypted token;
|
|
51
|
+
* this record itself never reads the token column.
|
|
52
|
+
*/
|
|
53
|
+
export interface ShareLinkSummary {
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly apiKeyId: string;
|
|
56
|
+
readonly kind: ShareLinkKind;
|
|
57
|
+
readonly active: boolean;
|
|
58
|
+
readonly createdAt: Date;
|
|
59
|
+
readonly expiresAt: Date | null;
|
|
60
|
+
readonly lastViewedAt: Date | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A stable link's encrypted token, or null when it predates token retention. */
|
|
64
|
+
export interface ShareLinkToken {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly apiKeyId: string;
|
|
67
|
+
readonly kind: ShareLinkKind;
|
|
68
|
+
readonly expiresAt: Date | null;
|
|
69
|
+
readonly tokenEncrypted: Buffer | null;
|
|
70
|
+
/** Public address fragment, held in the clear; null on pre-migration rows. */
|
|
71
|
+
readonly slug: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Policy fields a share recipient sees, whichever kind of link carried them.
|
|
76
|
+
* Both resolutions project the same columns, so the public payload and the
|
|
77
|
+
* model filter read one shape instead of two near-identical ones.
|
|
78
|
+
*/
|
|
79
|
+
export interface ShareLinkPolicy {
|
|
80
|
+
readonly id: string;
|
|
81
|
+
readonly tenantId: string;
|
|
82
|
+
readonly name: string;
|
|
83
|
+
readonly keyPrefix: string | null;
|
|
84
|
+
readonly requestsPerMinute: number | null;
|
|
85
|
+
readonly dailyTokenLimit: number | null;
|
|
86
|
+
readonly monthlyTokenLimit: number | null;
|
|
87
|
+
readonly lifetimeTokenBudget: number | null;
|
|
88
|
+
readonly maxConcurrentRequests: number | null;
|
|
89
|
+
readonly modelAccessMode: ApiKeyModelAccessMode | null;
|
|
90
|
+
readonly modelList: readonly string[] | null;
|
|
91
|
+
readonly modelPrefix: string | null;
|
|
92
|
+
readonly notesTitle: string | null;
|
|
93
|
+
readonly notesSubtitle: string | null;
|
|
94
|
+
readonly notesBody: string | null;
|
|
95
|
+
readonly sharePopupEnabled: boolean;
|
|
96
|
+
/** Uploaded popup art; the share page fetches it from its own image route. */
|
|
97
|
+
readonly sharePopupImage: Buffer | null;
|
|
98
|
+
readonly sharePopupImageMime: string | null;
|
|
99
|
+
readonly sharePopupTitle: string | null;
|
|
100
|
+
readonly sharePopupBody: string | null;
|
|
101
|
+
readonly expiresAt: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Public template fields authorized for a valid enrollment-link lookup. */
|
|
105
|
+
export interface ShareApiKeyRow extends ShareLinkPolicy {
|
|
106
|
+
readonly active: boolean;
|
|
107
|
+
readonly createdAt: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A personal key revealed by its handoff link. */
|
|
111
|
+
export interface ShareHandoffRow extends ShareLinkPolicy {
|
|
112
|
+
/** Encrypted personal credential; null when the row predates secret storage. */
|
|
113
|
+
readonly keyEncrypted: Buffer | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What one bearer token resolves to. A link is exactly one of these, so a
|
|
118
|
+
* public lookup answers with a discriminated result instead of the caller
|
|
119
|
+
* guessing which endpoint to try.
|
|
120
|
+
*/
|
|
121
|
+
export type ShareLinkResolution =
|
|
122
|
+
| { readonly kind: "enroll"; readonly key: ShareApiKeyRow }
|
|
123
|
+
| { readonly kind: "handoff"; readonly key: ShareHandoffRow };
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Why a share link stopped resolving.
|
|
127
|
+
*
|
|
128
|
+
* A paused key (`disabled`) is not a revoked one and not an expired link, but
|
|
129
|
+
* the resolver answered `null` for all three, so the page could only say
|
|
130
|
+
* "link unavailable" — which tells an operator nothing about whether to
|
|
131
|
+
* regenerate the link, re-enable the key, or issue a new one.
|
|
132
|
+
*/
|
|
133
|
+
export type ShareLinkRefusal = "not_found" | "expired" | "revoked" | "disabled";
|
|
134
|
+
|
|
135
|
+
export type ShareLinkOutcome =
|
|
136
|
+
| { readonly ok: true; readonly resolution: ShareLinkResolution }
|
|
137
|
+
| { readonly ok: false; readonly refusal: ShareLinkRefusal };
|
|
138
|
+
|
|
139
|
+
export interface SharedApiKeyMaterial {
|
|
140
|
+
readonly keyHash: string;
|
|
141
|
+
readonly keyPrefix: string;
|
|
142
|
+
readonly clientIp: string;
|
|
143
|
+
readonly clientIpKey: string;
|
|
144
|
+
/** Recipient-supplied name hint; the store composes the final label from it. */
|
|
145
|
+
readonly nameHint?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type SharedApiKeyIssueResult =
|
|
149
|
+
| {
|
|
150
|
+
readonly kind: "issued";
|
|
151
|
+
readonly apiKeyId: string;
|
|
152
|
+
readonly parentKeyId: string;
|
|
153
|
+
readonly tenantId: string;
|
|
154
|
+
readonly label: string;
|
|
155
|
+
readonly keyPrefix: string;
|
|
156
|
+
readonly createdAt: Date;
|
|
157
|
+
}
|
|
158
|
+
| { readonly kind: "link_unavailable" };
|
|
159
|
+
|
|
160
|
+
type ApiKeyRow = typeof apiKeys.$inferSelect;
|
|
161
|
+
type ShareLinkRow = typeof shareLinks.$inferSelect;
|
|
162
|
+
|
|
163
|
+
type PopupRow = typeof sharePopups.$inferSelect;
|
|
164
|
+
|
|
165
|
+
function mapShareRow(key: ApiKeyRow, link: ShareLinkRow, popup?: PopupRow): ShareApiKeyRow {
|
|
166
|
+
return {
|
|
167
|
+
id: key.id,
|
|
168
|
+
tenantId: key.tenantId,
|
|
169
|
+
name: key.label,
|
|
170
|
+
keyPrefix: key.keyPrefix,
|
|
171
|
+
active: key.revokedAt === null,
|
|
172
|
+
requestsPerMinute: key.requestsPerMinute,
|
|
173
|
+
dailyTokenLimit: key.dailyTokenLimit,
|
|
174
|
+
monthlyTokenLimit: key.monthlyTokenLimit,
|
|
175
|
+
lifetimeTokenBudget: key.lifetimeTokenBudget,
|
|
176
|
+
maxConcurrentRequests: key.maxConcurrentRequests,
|
|
177
|
+
modelAccessMode: key.modelAccessMode as ApiKeyModelAccessMode | null,
|
|
178
|
+
modelList: key.modelList as readonly string[] | null,
|
|
179
|
+
modelPrefix: key.modelPrefix,
|
|
180
|
+
notesTitle: key.notesTitle,
|
|
181
|
+
notesSubtitle: key.notesSubtitle,
|
|
182
|
+
notesBody: key.notesBody,
|
|
183
|
+
sharePopupEnabled: popup?.enabled ?? false,
|
|
184
|
+
sharePopupImage: popup?.image ?? null,
|
|
185
|
+
sharePopupImageMime: popup?.imageMime ?? null,
|
|
186
|
+
sharePopupTitle: popup?.title ?? null,
|
|
187
|
+
sharePopupBody: popup?.body ?? null,
|
|
188
|
+
createdAt: key.createdAt.toISOString(),
|
|
189
|
+
expiresAt: link.expiresAt?.toISOString() ?? null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function mapHandoffRow(key: ApiKeyRow, link: ShareLinkRow, popup?: PopupRow): ShareHandoffRow {
|
|
194
|
+
return {
|
|
195
|
+
id: key.id,
|
|
196
|
+
tenantId: key.tenantId,
|
|
197
|
+
name: key.label,
|
|
198
|
+
keyPrefix: key.keyPrefix,
|
|
199
|
+
keyEncrypted: key.keyEncrypted,
|
|
200
|
+
requestsPerMinute: key.requestsPerMinute,
|
|
201
|
+
dailyTokenLimit: key.dailyTokenLimit,
|
|
202
|
+
monthlyTokenLimit: key.monthlyTokenLimit,
|
|
203
|
+
lifetimeTokenBudget: key.lifetimeTokenBudget,
|
|
204
|
+
maxConcurrentRequests: key.maxConcurrentRequests,
|
|
205
|
+
modelAccessMode: key.modelAccessMode as ApiKeyModelAccessMode | null,
|
|
206
|
+
modelList: key.modelList as readonly string[] | null,
|
|
207
|
+
modelPrefix: key.modelPrefix,
|
|
208
|
+
notesTitle: key.notesTitle,
|
|
209
|
+
notesSubtitle: key.notesSubtitle,
|
|
210
|
+
notesBody: key.notesBody,
|
|
211
|
+
sharePopupEnabled: popup?.enabled ?? false,
|
|
212
|
+
sharePopupImage: popup?.image ?? null,
|
|
213
|
+
sharePopupImageMime: popup?.imageMime ?? null,
|
|
214
|
+
sharePopupTitle: popup?.title ?? null,
|
|
215
|
+
sharePopupBody: popup?.body ?? null,
|
|
216
|
+
expiresAt: link.expiresAt?.toISOString() ?? null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Persistence boundary for public enrollment links and child API keys. */
|
|
221
|
+
export interface ShareLinkStore {
|
|
222
|
+
/**
|
|
223
|
+
* Establishes the key's single link, or rotates it in place.
|
|
224
|
+
*
|
|
225
|
+
* One active link per key is the contract: calling this again replaces the
|
|
226
|
+
* token so the previous URL stops resolving. `rotate` false returns the
|
|
227
|
+
* existing link untouched.
|
|
228
|
+
*/
|
|
229
|
+
create(input: {
|
|
230
|
+
apiKeyId: string;
|
|
231
|
+
tokenHash: string;
|
|
232
|
+
tokenEncrypted: Buffer;
|
|
233
|
+
/** Public address fragment; kept across rotates so the URL is stable. */
|
|
234
|
+
slug: string;
|
|
235
|
+
kind: ShareLinkKind;
|
|
236
|
+
expiresAt: Date | null;
|
|
237
|
+
rotate: boolean;
|
|
238
|
+
}): Promise<ShareLinkRecord>;
|
|
239
|
+
/**
|
|
240
|
+
* Resolves a link's public address to the link it names, in one lookup.
|
|
241
|
+
*
|
|
242
|
+
* The argument is the raw path segment — a slug or a bearer token — not a
|
|
243
|
+
* hash: the store owns hashing, so a caller cannot hand it a slug-shaped
|
|
244
|
+
* value that only matches the hashed column.
|
|
245
|
+
*
|
|
246
|
+
* Serving enrollment and handoff through separate methods let the browser
|
|
247
|
+
* page and the router disagree about which one a given URL is: a personal
|
|
248
|
+
* key's handoff link resolved on one endpoint and 404'd on the other. One
|
|
249
|
+
* resolver cannot drift from itself.
|
|
250
|
+
*/
|
|
251
|
+
resolveShareLink(address: string): Promise<ShareLinkResolution | null>;
|
|
252
|
+
/** Same lookup as `resolveShareLink`, but names why a link stopped resolving. */
|
|
253
|
+
resolveShareLinkOutcome(address: string): Promise<ShareLinkOutcome>;
|
|
254
|
+
/** The key's active link with its retained token, for console re-display. */
|
|
255
|
+
findTokenForApiKey(apiKeyId: string): Promise<ShareLinkToken | null>;
|
|
256
|
+
/**
|
|
257
|
+
* Mints one child key under the link named by `address`.
|
|
258
|
+
*
|
|
259
|
+
* `address` is the raw path segment, so a slug-addressed link issues
|
|
260
|
+
* exactly like a token-addressed one.
|
|
261
|
+
*/
|
|
262
|
+
issueSharedApiKey(
|
|
263
|
+
address: string,
|
|
264
|
+
material: SharedApiKeyMaterial,
|
|
265
|
+
): Promise<SharedApiKeyIssueResult>;
|
|
266
|
+
/** Records a view of the link named by `address` (slug or bearer token). */
|
|
267
|
+
touchView(address: string): Promise<void>;
|
|
268
|
+
/** All enrollment links for one API key, newest first (never tokens). */
|
|
269
|
+
listForApiKey(apiKeyId: string): Promise<readonly ShareLinkSummary[]>;
|
|
270
|
+
/** Deactivates one enrollment link scoped to its owning parent API key. */
|
|
271
|
+
revoke(apiKeyId: string, shareId: string): Promise<boolean>;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export class DrizzleShareLinkStore implements ShareLinkStore {
|
|
275
|
+
constructor(private readonly db: CartethyiaDatabase) {}
|
|
276
|
+
|
|
277
|
+
async create(input: {
|
|
278
|
+
apiKeyId: string;
|
|
279
|
+
tokenHash: string;
|
|
280
|
+
tokenEncrypted: Buffer;
|
|
281
|
+
slug: string;
|
|
282
|
+
kind: ShareLinkKind;
|
|
283
|
+
expiresAt: Date | null;
|
|
284
|
+
rotate: boolean;
|
|
285
|
+
}): Promise<ShareLinkRecord> {
|
|
286
|
+
return await this.db.transaction(async (tx) => {
|
|
287
|
+
const existing = await lockRows(
|
|
288
|
+
tx
|
|
289
|
+
.select()
|
|
290
|
+
.from(shareLinks)
|
|
291
|
+
.where(and(eq(shareLinks.apiKeyId, input.apiKeyId), eq(shareLinks.active, true)))
|
|
292
|
+
.orderBy(desc(shareLinks.createdAt))
|
|
293
|
+
.limit(1),
|
|
294
|
+
);
|
|
295
|
+
const current = existing[0];
|
|
296
|
+
if (current && !input.rotate) {
|
|
297
|
+
return {
|
|
298
|
+
id: current.id,
|
|
299
|
+
apiKeyId: current.apiKeyId,
|
|
300
|
+
kind: current.kind,
|
|
301
|
+
createdAt: current.createdAt,
|
|
302
|
+
expiresAt: current.expiresAt,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (current) {
|
|
306
|
+
const updated = await tx
|
|
307
|
+
.update(shareLinks)
|
|
308
|
+
.set({
|
|
309
|
+
tokenHash: input.tokenHash,
|
|
310
|
+
tokenEncrypted: input.tokenEncrypted,
|
|
311
|
+
// The slug is the address and survives a rotate, so an operator who
|
|
312
|
+
// rotates does not invalidate every copy of the link they shared.
|
|
313
|
+
...(current.slug === null ? { slug: input.slug } : {}),
|
|
314
|
+
expiresAt: input.expiresAt,
|
|
315
|
+
lastViewedAt: null,
|
|
316
|
+
})
|
|
317
|
+
.where(eq(shareLinks.id, current.id))
|
|
318
|
+
.returning();
|
|
319
|
+
const row = updated[0];
|
|
320
|
+
if (!row) throw new Error("share link rotate returned no row");
|
|
321
|
+
return {
|
|
322
|
+
id: row.id,
|
|
323
|
+
apiKeyId: row.apiKeyId,
|
|
324
|
+
kind: row.kind,
|
|
325
|
+
createdAt: row.createdAt,
|
|
326
|
+
expiresAt: row.expiresAt,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const rows = await tx
|
|
330
|
+
.insert(shareLinks)
|
|
331
|
+
.values({
|
|
332
|
+
apiKeyId: input.apiKeyId,
|
|
333
|
+
tokenHash: input.tokenHash,
|
|
334
|
+
tokenEncrypted: input.tokenEncrypted,
|
|
335
|
+
slug: input.slug,
|
|
336
|
+
kind: input.kind,
|
|
337
|
+
expiresAt: input.expiresAt,
|
|
338
|
+
})
|
|
339
|
+
.returning();
|
|
340
|
+
const row = rows[0];
|
|
341
|
+
if (!row) throw new Error("share link insert returned no row");
|
|
342
|
+
return {
|
|
343
|
+
id: row.id,
|
|
344
|
+
apiKeyId: row.apiKeyId,
|
|
345
|
+
kind: row.kind,
|
|
346
|
+
createdAt: row.createdAt,
|
|
347
|
+
expiresAt: row.expiresAt,
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async resolveShareLink(address: string): Promise<ShareLinkResolution | null> {
|
|
353
|
+
const outcome = await this.resolveShareLinkOutcome(address);
|
|
354
|
+
return outcome.ok ? outcome.resolution : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Same lookup as {@link resolveShareLink}, but reports *why* a link stopped
|
|
359
|
+
* resolving instead of collapsing every cause into `null`. The refusal is
|
|
360
|
+
* decided from the row that actually exists: dropping the health predicates
|
|
361
|
+
* from the `WHERE` and classifying afterwards is the only way to tell an
|
|
362
|
+
* expired link from a revoked key from a paused one, since a row failing any
|
|
363
|
+
* of them is simply absent from the filtered result.
|
|
364
|
+
*/
|
|
365
|
+
async resolveShareLinkOutcome(address: string): Promise<ShareLinkOutcome> {
|
|
366
|
+
// Two address forms name one link: the public slug, and the bearer token
|
|
367
|
+
// a pre-slug page was issued with. They are matched in their own form —
|
|
368
|
+
// the slug is stored in the clear (that is how a restored deployment keeps
|
|
369
|
+
// its addresses), while the token is only ever stored as a hash. Comparing
|
|
370
|
+
// the slug against a hash can never match, which made every slug-addressed
|
|
371
|
+
// link answer "not found" for a page that had just been generated.
|
|
372
|
+
const rows = await this.db
|
|
373
|
+
.select({ key: apiKeys, link: shareLinks, popup: sharePopups })
|
|
374
|
+
.from(shareLinks)
|
|
375
|
+
.innerJoin(apiKeys, eq(shareLinks.apiKeyId, apiKeys.id))
|
|
376
|
+
.leftJoin(sharePopups, eq(sharePopups.apiKeyId, apiKeys.id))
|
|
377
|
+
.where(
|
|
378
|
+
and(
|
|
379
|
+
addressMatches(address),
|
|
380
|
+
isNull(apiKeys.parentKeyId),
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
.limit(1);
|
|
384
|
+
const row = rows[0];
|
|
385
|
+
if (row === undefined) return { ok: false, refusal: "not_found" };
|
|
386
|
+
// Ordering: a deactivated link was revoked by its owner, not expired —
|
|
387
|
+
// "expired" would send them to regenerate a link they deliberately killed.
|
|
388
|
+
// Beyond that, report the state the operator can actually change: a key
|
|
389
|
+
// can be both paused and expired, and either way the link needs
|
|
390
|
+
// regenerating, so the key's own state is the more actionable answer.
|
|
391
|
+
if (row.link.active !== true) return { ok: false, refusal: "revoked" };
|
|
392
|
+
if (row.key.revokedAt !== null) return { ok: false, refusal: "revoked" };
|
|
393
|
+
if (row.key.enabled !== true) return { ok: false, refusal: "disabled" };
|
|
394
|
+
if (row.link.expiresAt !== null && row.link.expiresAt.getTime() <= Date.now())
|
|
395
|
+
return { ok: false, refusal: "expired" };
|
|
396
|
+
// The link's kind and the key's mode must agree: an enrollment link hands
|
|
397
|
+
// out child keys from a share template, which has no credential of its own
|
|
398
|
+
// to reveal, while a handoff link reveals a personal key. A row where the
|
|
399
|
+
// two disagree is a mismatched pair, not a link to serve.
|
|
400
|
+
if (row.link.kind === "enroll") {
|
|
401
|
+
return row.key.keyMode === "share"
|
|
402
|
+
? { ok: true, resolution: { kind: "enroll", key: mapShareRow(row.key, row.link, row.popup ?? undefined) } }
|
|
403
|
+
: { ok: false, refusal: "not_found" };
|
|
404
|
+
}
|
|
405
|
+
return row.key.keyMode === "personal"
|
|
406
|
+
? { ok: true, resolution: { kind: "handoff", key: mapHandoffRow(row.key, row.link, row.popup ?? undefined) } }
|
|
407
|
+
: { ok: false, refusal: "not_found" };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async findTokenForApiKey(apiKeyId: string): Promise<ShareLinkToken | null> {
|
|
411
|
+
const rows = await this.db
|
|
412
|
+
.select({
|
|
413
|
+
id: shareLinks.id,
|
|
414
|
+
apiKeyId: shareLinks.apiKeyId,
|
|
415
|
+
kind: shareLinks.kind,
|
|
416
|
+
expiresAt: shareLinks.expiresAt,
|
|
417
|
+
tokenEncrypted: shareLinks.tokenEncrypted,
|
|
418
|
+
slug: shareLinks.slug,
|
|
419
|
+
})
|
|
420
|
+
.from(shareLinks)
|
|
421
|
+
.where(
|
|
422
|
+
and(
|
|
423
|
+
eq(shareLinks.apiKeyId, apiKeyId),
|
|
424
|
+
eq(shareLinks.active, true),
|
|
425
|
+
sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
.orderBy(desc(shareLinks.createdAt))
|
|
429
|
+
.limit(1);
|
|
430
|
+
return rows[0] ?? null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async issueSharedApiKey(
|
|
434
|
+
address: string,
|
|
435
|
+
material: SharedApiKeyMaterial,
|
|
436
|
+
): Promise<SharedApiKeyIssueResult> {
|
|
437
|
+
return this.db.transaction(async (tx) => {
|
|
438
|
+
// Read the link first without locking, then lock its parent before the
|
|
439
|
+
// link. Parent revocation uses the same lock order and is atomic with
|
|
440
|
+
// child revocation, so a concurrent issue cannot escape the parent.
|
|
441
|
+
const [linkRef] = await tx
|
|
442
|
+
.select({ id: shareLinks.id, apiKeyId: shareLinks.apiKeyId })
|
|
443
|
+
.from(shareLinks)
|
|
444
|
+
.where(
|
|
445
|
+
and(
|
|
446
|
+
addressMatches(address),
|
|
447
|
+
eq(shareLinks.kind, "enroll"),
|
|
448
|
+
eq(shareLinks.active, true),
|
|
449
|
+
sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
.limit(1);
|
|
453
|
+
if (!linkRef) return { kind: "link_unavailable" };
|
|
454
|
+
|
|
455
|
+
const [parent] = await lockRows(
|
|
456
|
+
tx
|
|
457
|
+
.select()
|
|
458
|
+
.from(apiKeys)
|
|
459
|
+
.where(
|
|
460
|
+
and(
|
|
461
|
+
eq(apiKeys.id, linkRef.apiKeyId),
|
|
462
|
+
eq(apiKeys.keyMode, "share"),
|
|
463
|
+
isNull(apiKeys.parentKeyId),
|
|
464
|
+
isNull(apiKeys.revokedAt),
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
.limit(1),
|
|
468
|
+
);
|
|
469
|
+
if (!parent) return { kind: "link_unavailable" };
|
|
470
|
+
|
|
471
|
+
const [activeLink] = await lockRows(
|
|
472
|
+
tx
|
|
473
|
+
.select({ id: shareLinks.id })
|
|
474
|
+
.from(shareLinks)
|
|
475
|
+
.where(
|
|
476
|
+
and(
|
|
477
|
+
eq(shareLinks.id, linkRef.id),
|
|
478
|
+
addressMatches(address),
|
|
479
|
+
eq(shareLinks.active, true),
|
|
480
|
+
sql`(${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > now())`,
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
.limit(1),
|
|
484
|
+
);
|
|
485
|
+
if (!activeLink) return { kind: "link_unavailable" };
|
|
486
|
+
|
|
487
|
+
// The label is `hint + random`, capped at 12 chars — never the
|
|
488
|
+
// parent's full label plus a suffix, which grew past every table
|
|
489
|
+
// column it rendered in. Built here (not imported from the console
|
|
490
|
+
// domain) because persistence never imports console code.
|
|
491
|
+
const hint = (material.nameHint ?? "").trim().slice(0, 7);
|
|
492
|
+
const suffix = randomInt(1000, 10_000).toString();
|
|
493
|
+
const label =
|
|
494
|
+
hint.length === 0 ? `key-${suffix}` : `${hint}-${suffix}`.slice(0, 12);
|
|
495
|
+
const [child] = await tx
|
|
496
|
+
.insert(apiKeys)
|
|
497
|
+
.values({
|
|
498
|
+
tenantId: parent.tenantId,
|
|
499
|
+
keyHash: material.keyHash,
|
|
500
|
+
keyMode: "share",
|
|
501
|
+
parentKeyId: parent.id,
|
|
502
|
+
issuedClientIp: material.clientIp,
|
|
503
|
+
issuedClientIpKey: material.clientIpKey,
|
|
504
|
+
label,
|
|
505
|
+
scopes: parent.scopes,
|
|
506
|
+
keyPrefix: material.keyPrefix,
|
|
507
|
+
keyEncrypted: null,
|
|
508
|
+
notesTitle: parent.notesTitle,
|
|
509
|
+
notesSubtitle: parent.notesSubtitle,
|
|
510
|
+
notesBody: parent.notesBody,
|
|
511
|
+
requestsPerMinute: parent.requestsPerMinute,
|
|
512
|
+
dailyTokenLimit: parent.dailyTokenLimit,
|
|
513
|
+
monthlyTokenLimit: parent.monthlyTokenLimit,
|
|
514
|
+
lifetimeTokenBudget: parent.lifetimeTokenBudget,
|
|
515
|
+
maxConcurrentRequests: parent.maxConcurrentRequests,
|
|
516
|
+
modelPrefix: parent.modelPrefix,
|
|
517
|
+
modelAccessMode: parent.modelAccessMode,
|
|
518
|
+
modelList: parent.modelList,
|
|
519
|
+
clientRouterDenylist: parent.clientRouterDenylist,
|
|
520
|
+
lifetimeTokensConsumed: 0,
|
|
521
|
+
})
|
|
522
|
+
.returning({ id: apiKeys.id, tenantId: apiKeys.tenantId, createdAt: apiKeys.createdAt });
|
|
523
|
+
if (!child) throw new Error("shared API-key insert returned no row");
|
|
524
|
+
return {
|
|
525
|
+
kind: "issued",
|
|
526
|
+
apiKeyId: child.id,
|
|
527
|
+
parentKeyId: parent.id,
|
|
528
|
+
tenantId: child.tenantId,
|
|
529
|
+
label,
|
|
530
|
+
keyPrefix: material.keyPrefix,
|
|
531
|
+
createdAt: child.createdAt,
|
|
532
|
+
};
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async touchView(address: string): Promise<void> {
|
|
537
|
+
// Expired-but-active links must not bump lastViewedAt: the bump makes
|
|
538
|
+
// expiry auditing lie about a link that no longer serves.
|
|
539
|
+
await this.db
|
|
540
|
+
.update(shareLinks)
|
|
541
|
+
.set({ lastViewedAt: new Date() })
|
|
542
|
+
.where(
|
|
543
|
+
and(
|
|
544
|
+
addressMatches(address),
|
|
545
|
+
eq(shareLinks.active, true),
|
|
546
|
+
sql`${shareLinks.expiresAt} IS NULL OR ${shareLinks.expiresAt} > NOW()`,
|
|
547
|
+
),
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async listForApiKey(apiKeyId: string): Promise<readonly ShareLinkSummary[]> {
|
|
552
|
+
const rows = await this.db
|
|
553
|
+
.select()
|
|
554
|
+
.from(shareLinks)
|
|
555
|
+
.where(and(eq(shareLinks.apiKeyId, apiKeyId), eq(shareLinks.kind, "enroll")))
|
|
556
|
+
.orderBy(desc(shareLinks.createdAt));
|
|
557
|
+
return rows.map((row) => ({
|
|
558
|
+
id: row.id,
|
|
559
|
+
apiKeyId: row.apiKeyId,
|
|
560
|
+
kind: "enroll",
|
|
561
|
+
active: row.active,
|
|
562
|
+
createdAt: row.createdAt,
|
|
563
|
+
expiresAt: row.expiresAt,
|
|
564
|
+
lastViewedAt: row.lastViewedAt,
|
|
565
|
+
}));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async revoke(apiKeyId: string, shareId: string): Promise<boolean> {
|
|
569
|
+
const rows = await this.db
|
|
570
|
+
.update(shareLinks)
|
|
571
|
+
.set({ active: false })
|
|
572
|
+
.where(
|
|
573
|
+
and(
|
|
574
|
+
eq(shareLinks.id, shareId),
|
|
575
|
+
eq(shareLinks.apiKeyId, apiKeyId),
|
|
576
|
+
eq(shareLinks.kind, "enroll"),
|
|
577
|
+
eq(shareLinks.active, true),
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
.returning({ id: shareLinks.id });
|
|
581
|
+
return rows.length > 0;
|
|
582
|
+
}
|
|
583
|
+
}
|