@frockbot/plugin-credentials 0.0.0 → 0.1.1

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/src/user.ts ADDED
@@ -0,0 +1,1142 @@
1
+ import {
2
+ decodeCredentialEnvelopeV1,
3
+ decodeCredentialLeaseV1,
4
+ type CredentialEnvelopeV1,
5
+ type CredentialLeaseV1,
6
+ openCredentialV1,
7
+ parseCredentialKeyringV1,
8
+ sealCredentialV1,
9
+ } from "@frockbot/connection-core";
10
+ import type { Plugin } from "cordis";
11
+ export {
12
+ createCredentialRuntimePlugin,
13
+ CredentialLeaseRuntime,
14
+ } from "./runtime.js";
15
+
16
+ const CREDENTIAL_PREFIX = "credential:";
17
+ const ACTIVE_PREFIX = "credential-active:";
18
+ const LEASE_PREFIX = "credential-lease:";
19
+ const LEASE_TOMBSTONE_PREFIX = "credential-lease-expired:";
20
+ const LEASE_QUEUE_STATE_KEY = "credential-lease-queue";
21
+ const LEASE_QUEUE_PAGE_PREFIX = "credential-lease-queue-page:";
22
+ const LEASE_QUEUE_POINTER_PREFIX = "credential-lease-queue-pointer:";
23
+ const LEASE_GENERATION_INDEX_PREFIX = "credential-lease-generation-index:";
24
+ const LEASE_TOMBSTONE_INDEX_PREFIX = "credential-lease-expired-index:";
25
+ const MAX_GENERATION_LEASE_RECORDS = 64;
26
+ const MAX_LEASE_RECOVERIES_PER_ALARM = 64;
27
+
28
+ export interface CredentialTransaction {
29
+ get<T>(key: string): Promise<T | undefined>;
30
+ put<T>(key: string, value: T): Promise<void>;
31
+ put(entries: Record<string, unknown>): Promise<void>;
32
+ delete(key: string): Promise<boolean>;
33
+ getAlarm?(): Promise<number | null>;
34
+ setAlarm?(scheduledTime: number | Date): Promise<void>;
35
+ }
36
+
37
+ export interface CredentialStorage extends CredentialTransaction {
38
+ transaction<T>(
39
+ callback: (storage: CredentialTransaction) => Promise<T>,
40
+ ): Promise<T>;
41
+ }
42
+
43
+ export interface CredentialUserBackendHost {
44
+ storage: CredentialStorage & {
45
+ getAlarm?(): Promise<number | null>;
46
+ setAlarm?(scheduledTime: number | Date): Promise<void>;
47
+ };
48
+ keyring: string;
49
+ now?: () => number;
50
+ }
51
+
52
+ export interface PreparedApiKeyCredential {
53
+ accountId: string;
54
+ connectionId: string;
55
+ packageId: string;
56
+ generation: string;
57
+ envelope: CredentialEnvelopeV1;
58
+ }
59
+
60
+ interface StoredCredentialGeneration {
61
+ schemaVersion: 1;
62
+ accountId: string;
63
+ connectionId: string;
64
+ packageId: string;
65
+ generation: string;
66
+ state: "pending" | "active" | "retired";
67
+ envelope: CredentialEnvelopeV1;
68
+ leaseIds: string[];
69
+ }
70
+
71
+ interface StoredCredentialLease extends CredentialLeaseV1 {
72
+ accountId: string;
73
+ packageId: string;
74
+ settled: boolean;
75
+ }
76
+
77
+ interface StoredLeaseQueue {
78
+ schemaVersion: 1;
79
+ headPage: number;
80
+ tailPage: number;
81
+ scanPage: number | null;
82
+ scanMinimum: number | null;
83
+ nextAlarm: number | null;
84
+ }
85
+
86
+ function credentialKey(connectionId: string, generation: string): string {
87
+ return `${CREDENTIAL_PREFIX}${connectionId}:${generation}`;
88
+ }
89
+
90
+ function activeKey(connectionId: string): string {
91
+ return `${ACTIVE_PREFIX}${connectionId}`;
92
+ }
93
+
94
+ function leaseKey(effectId: string): string {
95
+ return `${LEASE_PREFIX}${effectId}`;
96
+ }
97
+
98
+ function leaseTombstoneKey(effectId: string): string {
99
+ return `${LEASE_TOMBSTONE_PREFIX}${effectId}`;
100
+ }
101
+
102
+ function leaseQueuePageKey(page: number): string {
103
+ return `${LEASE_QUEUE_PAGE_PREFIX}${page}`;
104
+ }
105
+
106
+ function leaseQueuePointerKey(effectId: string): string {
107
+ return `${LEASE_QUEUE_POINTER_PREFIX}${effectId}`;
108
+ }
109
+
110
+ function leaseGenerationIndexKey(
111
+ connectionId: string,
112
+ generation: string,
113
+ ): string {
114
+ return `${LEASE_GENERATION_INDEX_PREFIX}${connectionId}:${generation}`;
115
+ }
116
+
117
+ function leaseTombstoneIndexKey(
118
+ connectionId: string,
119
+ generation: string,
120
+ ): string {
121
+ return `${LEASE_TOMBSTONE_INDEX_PREFIX}${connectionId}:${generation}`;
122
+ }
123
+
124
+ function storedRecord(
125
+ input: unknown,
126
+ label: string,
127
+ fields: readonly string[],
128
+ ): Record<string, unknown> {
129
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
130
+ throw new Error(`${label} is invalid`);
131
+ }
132
+ const value = input as Record<string, unknown>;
133
+ if (
134
+ !fields.every((field) => Object.hasOwn(value, field)) ||
135
+ Object.keys(value).some((field) => !fields.includes(field))
136
+ ) {
137
+ throw new Error(`${label} is invalid`);
138
+ }
139
+ return value;
140
+ }
141
+
142
+ function storedText(value: unknown, label: string, maximum = 256): string {
143
+ if (
144
+ typeof value !== "string" ||
145
+ value.length === 0 ||
146
+ value.length > maximum
147
+ ) {
148
+ throw new Error(`${label} is invalid`);
149
+ }
150
+ return value;
151
+ }
152
+
153
+ function decodeStoredStringList(input: unknown, label: string): string[] {
154
+ if (!Array.isArray(input)) throw new Error(`${label} is invalid`);
155
+ return [...new Set(input.map((value) => storedText(value, label)))];
156
+ }
157
+
158
+ function storedQueueNumber(value: unknown, label: string): number {
159
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
160
+ throw new Error(`${label} is invalid`);
161
+ }
162
+ return value as number;
163
+ }
164
+
165
+ function decodeStoredLeaseQueue(input: unknown): StoredLeaseQueue {
166
+ const value = storedRecord(input, "Stored credential lease queue", [
167
+ "schemaVersion",
168
+ "headPage",
169
+ "tailPage",
170
+ "scanPage",
171
+ "scanMinimum",
172
+ "nextAlarm",
173
+ ]);
174
+ if (value.schemaVersion !== 1) {
175
+ throw new Error("Stored credential lease queue is invalid");
176
+ }
177
+ const optionalNumber = (candidate: unknown, label: string) =>
178
+ candidate === null ? null : storedQueueNumber(candidate, label);
179
+ const headPage = storedQueueNumber(value.headPage, "headPage");
180
+ const tailPage = storedQueueNumber(value.tailPage, "tailPage");
181
+ const scanPage = optionalNumber(value.scanPage, "scanPage");
182
+ if (
183
+ headPage > tailPage ||
184
+ (scanPage !== null && (scanPage < headPage || scanPage > tailPage))
185
+ ) {
186
+ throw new Error("Stored credential lease queue is invalid");
187
+ }
188
+ return {
189
+ schemaVersion: 1,
190
+ headPage,
191
+ tailPage,
192
+ scanPage,
193
+ scanMinimum: optionalNumber(value.scanMinimum, "scanMinimum"),
194
+ nextAlarm: optionalNumber(value.nextAlarm, "nextAlarm"),
195
+ };
196
+ }
197
+
198
+ function decodeLeaseQueuePage(input: unknown): string[] {
199
+ const page = decodeStoredStringList(input, "credential lease queue page");
200
+ if (page.length > MAX_LEASE_RECOVERIES_PER_ALARM) {
201
+ throw new Error("credential lease queue page is invalid");
202
+ }
203
+ return page;
204
+ }
205
+
206
+ function decodeStoredCredentialGeneration(
207
+ input: unknown,
208
+ ): StoredCredentialGeneration {
209
+ const value = storedRecord(input, "Stored credential generation", [
210
+ "schemaVersion",
211
+ "accountId",
212
+ "connectionId",
213
+ "packageId",
214
+ "generation",
215
+ "state",
216
+ "envelope",
217
+ "leaseIds",
218
+ ]);
219
+ if (
220
+ value.schemaVersion !== 1 ||
221
+ (value.state !== "pending" &&
222
+ value.state !== "active" &&
223
+ value.state !== "retired")
224
+ ) {
225
+ throw new Error("Stored credential generation is invalid");
226
+ }
227
+ const generation = storedText(value.generation, "generation", 128);
228
+ const envelope = decodeCredentialEnvelopeV1(value.envelope);
229
+ if (envelope.credentialGeneration !== generation) {
230
+ throw new Error("Stored credential generation is invalid");
231
+ }
232
+ return {
233
+ schemaVersion: 1,
234
+ accountId: storedText(value.accountId, "accountId"),
235
+ connectionId: storedText(value.connectionId, "connectionId", 128),
236
+ packageId: storedText(value.packageId, "packageId", 128),
237
+ generation,
238
+ state: value.state,
239
+ envelope,
240
+ leaseIds: decodeStoredStringList(value.leaseIds, "leaseIds"),
241
+ };
242
+ }
243
+
244
+ function decodeStoredCredentialLease(input: unknown): StoredCredentialLease {
245
+ const value = storedRecord(input, "Stored credential lease", [
246
+ "schemaVersion",
247
+ "leaseId",
248
+ "effectId",
249
+ "accountId",
250
+ "connectionId",
251
+ "packageId",
252
+ "credentialGeneration",
253
+ "expiresAt",
254
+ "envelope",
255
+ "settled",
256
+ ]);
257
+ if (typeof value.settled !== "boolean") {
258
+ throw new Error("Stored credential lease is invalid");
259
+ }
260
+ const lease = decodeCredentialLeaseV1({
261
+ schemaVersion: value.schemaVersion,
262
+ leaseId: value.leaseId,
263
+ effectId: value.effectId,
264
+ connectionId: value.connectionId,
265
+ credentialGeneration: value.credentialGeneration,
266
+ expiresAt: value.expiresAt,
267
+ envelope: value.envelope,
268
+ });
269
+ return {
270
+ ...lease,
271
+ accountId: storedText(value.accountId, "accountId"),
272
+ packageId: storedText(value.packageId, "packageId", 128),
273
+ settled: value.settled,
274
+ };
275
+ }
276
+
277
+ function decodeLeaseTombstone(input: unknown): {
278
+ accountId: string;
279
+ connectionId: string;
280
+ packageId: string;
281
+ credentialGeneration: string;
282
+ } {
283
+ const value = storedRecord(input, "Stored credential lease tombstone", [
284
+ "accountId",
285
+ "connectionId",
286
+ "packageId",
287
+ "credentialGeneration",
288
+ ]);
289
+ return {
290
+ accountId: storedText(value.accountId, "accountId"),
291
+ connectionId: storedText(value.connectionId, "connectionId", 128),
292
+ packageId: storedText(value.packageId, "packageId", 128),
293
+ credentialGeneration: storedText(
294
+ value.credentialGeneration,
295
+ "credentialGeneration",
296
+ 128,
297
+ ),
298
+ };
299
+ }
300
+
301
+ function decodeStoredGenerationId(input: unknown): string {
302
+ return storedText(input, "Stored credential generation id", 128);
303
+ }
304
+
305
+ function requireGeneration(
306
+ value: unknown,
307
+ connectionId: string,
308
+ generation: string,
309
+ ): StoredCredentialGeneration {
310
+ if (value === undefined) {
311
+ throw new Error("Credential generation is unavailable");
312
+ }
313
+ const decoded = decodeStoredCredentialGeneration(value);
314
+ if (
315
+ decoded.connectionId !== connectionId ||
316
+ decoded.generation !== generation
317
+ ) {
318
+ throw new Error("Credential generation is unavailable");
319
+ }
320
+ return decoded;
321
+ }
322
+
323
+ export class CredentialUserBackendContribution {
324
+ private readonly keyring;
325
+ private readonly now: () => number;
326
+
327
+ constructor(private readonly host: CredentialUserBackendHost) {
328
+ this.keyring = parseCredentialKeyringV1(host.keyring);
329
+ this.now = host.now ?? Date.now;
330
+ }
331
+
332
+ private async enqueueLease(
333
+ storage: CredentialTransaction,
334
+ effectId: string,
335
+ expiresAt: number,
336
+ ): Promise<void> {
337
+ const stateValue = await storage.get<unknown>(LEASE_QUEUE_STATE_KEY);
338
+ let state: StoredLeaseQueue =
339
+ stateValue === undefined
340
+ ? {
341
+ schemaVersion: 1,
342
+ headPage: 0,
343
+ tailPage: 0,
344
+ scanPage: null,
345
+ scanMinimum: null,
346
+ nextAlarm: null,
347
+ }
348
+ : decodeStoredLeaseQueue(stateValue);
349
+ let pageValue = await storage.get<unknown>(
350
+ leaseQueuePageKey(state.tailPage),
351
+ );
352
+ let page = pageValue === undefined ? [] : decodeLeaseQueuePage(pageValue);
353
+ if (page.length >= MAX_LEASE_RECOVERIES_PER_ALARM) {
354
+ state = { ...state, tailPage: state.tailPage + 1 };
355
+ pageValue = await storage.get<unknown>(leaseQueuePageKey(state.tailPage));
356
+ page = pageValue === undefined ? [] : decodeLeaseQueuePage(pageValue);
357
+ }
358
+ await storage.put({
359
+ [leaseQueuePageKey(state.tailPage)]: [...page, effectId],
360
+ [leaseQueuePointerKey(effectId)]: state.tailPage,
361
+ [LEASE_QUEUE_STATE_KEY]: {
362
+ ...state,
363
+ nextAlarm:
364
+ state.nextAlarm === null
365
+ ? expiresAt
366
+ : Math.min(state.nextAlarm, expiresAt),
367
+ } satisfies StoredLeaseQueue,
368
+ });
369
+ }
370
+
371
+ async prepareApiKey(input: {
372
+ accountId: string;
373
+ connectionId: string;
374
+ packageId: string;
375
+ generation: string;
376
+ apiKey: string;
377
+ now?: string;
378
+ }): Promise<PreparedApiKeyCredential> {
379
+ return {
380
+ accountId: input.accountId,
381
+ connectionId: input.connectionId,
382
+ packageId: input.packageId,
383
+ generation: input.generation,
384
+ envelope: await sealCredentialV1({
385
+ keyring: this.keyring,
386
+ context: {
387
+ accountId: input.accountId,
388
+ connectionId: input.connectionId,
389
+ packageId: input.packageId,
390
+ credentialGeneration: input.generation,
391
+ },
392
+ plaintext: input.apiKey,
393
+ createdAt: input.now,
394
+ }),
395
+ };
396
+ }
397
+
398
+ async stagePreparedApiKey(
399
+ input: PreparedApiKeyCredential,
400
+ storage: CredentialTransaction = this.host.storage,
401
+ ): Promise<void> {
402
+ const key = credentialKey(input.connectionId, input.generation);
403
+ const existingValue = await storage.get<unknown>(key);
404
+ const existing =
405
+ existingValue === undefined
406
+ ? undefined
407
+ : decodeStoredCredentialGeneration(existingValue);
408
+ if (existing) {
409
+ if (
410
+ existing.accountId !== input.accountId ||
411
+ existing.packageId !== input.packageId
412
+ ) {
413
+ throw new Error("Credential generation authority does not match");
414
+ }
415
+ return;
416
+ }
417
+ await storage.put(key, {
418
+ schemaVersion: 1,
419
+ accountId: input.accountId,
420
+ connectionId: input.connectionId,
421
+ packageId: input.packageId,
422
+ generation: input.generation,
423
+ state: "pending",
424
+ envelope: input.envelope,
425
+ leaseIds: [],
426
+ } satisfies StoredCredentialGeneration);
427
+ }
428
+
429
+ async stageApiKey(input: {
430
+ accountId: string;
431
+ connectionId: string;
432
+ packageId: string;
433
+ generation: string;
434
+ apiKey: string;
435
+ now?: string;
436
+ }): Promise<void> {
437
+ await this.stagePreparedApiKey(await this.prepareApiKey(input));
438
+ }
439
+
440
+ async readStagedApiKey(input: {
441
+ accountId: string;
442
+ connectionId: string;
443
+ packageId: string;
444
+ generation: string;
445
+ }): Promise<string> {
446
+ const stored = requireGeneration(
447
+ await this.host.storage.get<unknown>(
448
+ credentialKey(input.connectionId, input.generation),
449
+ ),
450
+ input.connectionId,
451
+ input.generation,
452
+ );
453
+ if (
454
+ stored.accountId !== input.accountId ||
455
+ stored.packageId !== input.packageId ||
456
+ stored.state !== "pending"
457
+ ) {
458
+ throw new Error("Pending credential authority does not match");
459
+ }
460
+ return openCredentialV1({
461
+ keyring: this.keyring,
462
+ context: {
463
+ accountId: stored.accountId,
464
+ connectionId: stored.connectionId,
465
+ packageId: stored.packageId,
466
+ credentialGeneration: stored.generation,
467
+ },
468
+ envelope: stored.envelope,
469
+ });
470
+ }
471
+
472
+ async activate(
473
+ input: {
474
+ accountId: string;
475
+ connectionId: string;
476
+ packageId: string;
477
+ generation: string;
478
+ },
479
+ storage?: CredentialTransaction,
480
+ ): Promise<void> {
481
+ const activate = async (transaction: CredentialTransaction) => {
482
+ const next = requireGeneration(
483
+ await transaction.get<unknown>(
484
+ credentialKey(input.connectionId, input.generation),
485
+ ),
486
+ input.connectionId,
487
+ input.generation,
488
+ );
489
+ if (
490
+ next.accountId !== input.accountId ||
491
+ next.packageId !== input.packageId
492
+ ) {
493
+ throw new Error("Credential generation authority does not match");
494
+ }
495
+ const currentGenerationValue = await transaction.get<unknown>(
496
+ activeKey(input.connectionId),
497
+ );
498
+ const currentGeneration =
499
+ currentGenerationValue === undefined
500
+ ? undefined
501
+ : decodeStoredGenerationId(currentGenerationValue);
502
+ const entries: Record<string, unknown> = {
503
+ [credentialKey(input.connectionId, input.generation)]: {
504
+ ...next,
505
+ state: "active",
506
+ } satisfies StoredCredentialGeneration,
507
+ [activeKey(input.connectionId)]: input.generation,
508
+ };
509
+ let obsoleteGenerationKey: string | undefined;
510
+ if (currentGeneration && currentGeneration !== input.generation) {
511
+ const current = requireGeneration(
512
+ await transaction.get<unknown>(
513
+ credentialKey(input.connectionId, currentGeneration),
514
+ ),
515
+ input.connectionId,
516
+ currentGeneration,
517
+ );
518
+ const currentKey = credentialKey(input.connectionId, currentGeneration);
519
+ if (current.leaseIds.length === 0) {
520
+ obsoleteGenerationKey = currentKey;
521
+ } else {
522
+ entries[currentKey] = {
523
+ ...current,
524
+ state: "retired",
525
+ } satisfies StoredCredentialGeneration;
526
+ }
527
+ }
528
+ if (currentGeneration && currentGeneration !== input.generation) {
529
+ const indexKey = leaseTombstoneIndexKey(
530
+ input.connectionId,
531
+ currentGeneration,
532
+ );
533
+ const tombstoneIndexValue = await transaction.get<unknown>(indexKey);
534
+ const tombstoneIndex =
535
+ tombstoneIndexValue === undefined
536
+ ? []
537
+ : decodeStoredStringList(
538
+ tombstoneIndexValue,
539
+ "credential lease tombstone index",
540
+ );
541
+ for (const effectId of tombstoneIndex) {
542
+ const tombstoneValue = await transaction.get<unknown>(
543
+ leaseTombstoneKey(effectId),
544
+ );
545
+ if (tombstoneValue === undefined) continue;
546
+ const tombstone = decodeLeaseTombstone(tombstoneValue);
547
+ if (
548
+ tombstone.connectionId !== input.connectionId ||
549
+ tombstone.credentialGeneration !== currentGeneration
550
+ ) {
551
+ throw new Error("Credential lease tombstone index is invalid");
552
+ }
553
+ await transaction.delete(leaseTombstoneKey(effectId));
554
+ }
555
+ entries[indexKey] = [];
556
+ }
557
+ await transaction.put(entries);
558
+ if (obsoleteGenerationKey && currentGeneration) {
559
+ await transaction.delete(obsoleteGenerationKey);
560
+ await transaction.delete(
561
+ leaseGenerationIndexKey(input.connectionId, currentGeneration),
562
+ );
563
+ await transaction.delete(
564
+ leaseTombstoneIndexKey(input.connectionId, currentGeneration),
565
+ );
566
+ }
567
+ };
568
+ await (storage
569
+ ? activate(storage)
570
+ : this.host.storage.transaction(activate));
571
+ }
572
+
573
+ async discardPending(
574
+ connectionId: string,
575
+ generation: string,
576
+ storage?: CredentialTransaction,
577
+ ): Promise<void> {
578
+ const discard = async (transaction: CredentialTransaction) => {
579
+ const key = credentialKey(connectionId, generation);
580
+ const storedValue = await transaction.get<unknown>(key);
581
+ if (storedValue === undefined) return;
582
+ const stored = decodeStoredCredentialGeneration(storedValue);
583
+ if (stored.state !== "pending") return;
584
+ if (stored.leaseIds.length === 0) {
585
+ const tombstoneIndexKey = leaseTombstoneIndexKey(
586
+ connectionId,
587
+ generation,
588
+ );
589
+ const tombstoneIndexValue =
590
+ await transaction.get<unknown>(tombstoneIndexKey);
591
+ const tombstoneIndex =
592
+ tombstoneIndexValue === undefined
593
+ ? []
594
+ : decodeStoredStringList(
595
+ tombstoneIndexValue,
596
+ "credential lease tombstone index",
597
+ );
598
+ for (const effectId of tombstoneIndex) {
599
+ const tombstoneValue = await transaction.get<unknown>(
600
+ leaseTombstoneKey(effectId),
601
+ );
602
+ if (tombstoneValue === undefined) continue;
603
+ const tombstone = decodeLeaseTombstone(tombstoneValue);
604
+ if (
605
+ tombstone.accountId !== stored.accountId ||
606
+ tombstone.connectionId !== connectionId ||
607
+ tombstone.packageId !== stored.packageId ||
608
+ tombstone.credentialGeneration !== generation
609
+ ) {
610
+ throw new Error("Credential lease tombstone index is invalid");
611
+ }
612
+ await transaction.delete(leaseTombstoneKey(effectId));
613
+ }
614
+ await transaction.delete(tombstoneIndexKey);
615
+ await transaction.delete(
616
+ leaseGenerationIndexKey(connectionId, generation),
617
+ );
618
+ await transaction.delete(key);
619
+ } else {
620
+ await transaction.put(key, { ...stored, state: "retired" });
621
+ }
622
+ };
623
+ if (storage) return discard(storage);
624
+ return this.host.storage.transaction(discard);
625
+ }
626
+
627
+ async replayLease(input: {
628
+ accountId: string;
629
+ connectionId: string;
630
+ packageId: string;
631
+ effectId: string;
632
+ }): Promise<CredentialLeaseV1 | undefined> {
633
+ const storedValue = await this.host.storage.get<unknown>(
634
+ leaseKey(input.effectId),
635
+ );
636
+ if (storedValue === undefined) return undefined;
637
+ const stored = decodeStoredCredentialLease(storedValue);
638
+ if (
639
+ stored.accountId !== input.accountId ||
640
+ stored.connectionId !== input.connectionId ||
641
+ stored.packageId !== input.packageId
642
+ ) {
643
+ throw new Error("Credential lease effect id was reused");
644
+ }
645
+ if (Date.parse(stored.expiresAt) <= this.now()) {
646
+ await this.expireLeases();
647
+ throw new Error("Credential lease expired");
648
+ }
649
+ return this.publicLease(stored);
650
+ }
651
+
652
+ async lease(
653
+ input: {
654
+ accountId: string;
655
+ connectionId: string;
656
+ packageId: string;
657
+ effectId: string;
658
+ expiresAt: string;
659
+ expectedGeneration: string;
660
+ credentialState?: "active" | "pending";
661
+ },
662
+ storage?: CredentialTransaction,
663
+ ): Promise<CredentialLeaseV1> {
664
+ const expiresAt = Date.parse(input.expiresAt);
665
+ if (!Number.isFinite(expiresAt) || expiresAt <= this.now()) {
666
+ throw new Error("Credential lease expiry is invalid");
667
+ }
668
+ if (!storage) await this.expireLeases();
669
+ const issue = async (transaction: CredentialTransaction) => {
670
+ const expiredValue = await transaction.get<unknown>(
671
+ leaseTombstoneKey(input.effectId),
672
+ );
673
+ const expired =
674
+ expiredValue === undefined
675
+ ? undefined
676
+ : decodeLeaseTombstone(expiredValue);
677
+ if (expired) {
678
+ if (
679
+ expired.accountId !== input.accountId ||
680
+ expired.connectionId !== input.connectionId ||
681
+ expired.packageId !== input.packageId
682
+ ) {
683
+ throw new Error("Credential lease effect id was reused");
684
+ }
685
+ throw new Error("Credential lease expired");
686
+ }
687
+ const existingValue = await transaction.get<unknown>(
688
+ leaseKey(input.effectId),
689
+ );
690
+ const existing =
691
+ existingValue === undefined
692
+ ? undefined
693
+ : decodeStoredCredentialLease(existingValue);
694
+ if (existing) {
695
+ if (
696
+ existing.accountId !== input.accountId ||
697
+ existing.connectionId !== input.connectionId ||
698
+ existing.packageId !== input.packageId
699
+ ) {
700
+ throw new Error("Credential lease effect id was reused");
701
+ }
702
+ return this.publicLease(existing);
703
+ }
704
+ const credentialState = input.credentialState ?? "active";
705
+ const activeGenerationValue =
706
+ credentialState === "active"
707
+ ? await transaction.get<unknown>(activeKey(input.connectionId))
708
+ : undefined;
709
+ const generation =
710
+ credentialState === "pending"
711
+ ? input.expectedGeneration
712
+ : activeGenerationValue === undefined
713
+ ? undefined
714
+ : decodeStoredGenerationId(activeGenerationValue);
715
+ if (!generation || generation !== input.expectedGeneration) {
716
+ throw new Error("Connection credential is unavailable");
717
+ }
718
+ const stored = requireGeneration(
719
+ await transaction.get<unknown>(
720
+ credentialKey(input.connectionId, generation),
721
+ ),
722
+ input.connectionId,
723
+ generation,
724
+ );
725
+ if (
726
+ stored.accountId !== input.accountId ||
727
+ stored.packageId !== input.packageId ||
728
+ stored.state !== credentialState
729
+ ) {
730
+ throw new Error("Connection credential is unavailable");
731
+ }
732
+ const generationLeaseIndexKey = leaseGenerationIndexKey(
733
+ input.connectionId,
734
+ generation,
735
+ );
736
+ const generationLeaseIndexValue = await transaction.get<unknown>(
737
+ generationLeaseIndexKey,
738
+ );
739
+ const generationLeaseIndex =
740
+ generationLeaseIndexValue === undefined
741
+ ? []
742
+ : decodeStoredStringList(
743
+ generationLeaseIndexValue,
744
+ "credential generation lease index",
745
+ );
746
+ const tombstoneIndexKey = leaseTombstoneIndexKey(
747
+ input.connectionId,
748
+ generation,
749
+ );
750
+ const tombstoneIndexValue =
751
+ await transaction.get<unknown>(tombstoneIndexKey);
752
+ const tombstoneIndex =
753
+ tombstoneIndexValue === undefined
754
+ ? []
755
+ : decodeStoredStringList(
756
+ tombstoneIndexValue,
757
+ "credential lease tombstone index",
758
+ );
759
+ if (
760
+ tombstoneIndex.length + generationLeaseIndex.length >=
761
+ MAX_GENERATION_LEASE_RECORDS
762
+ ) {
763
+ throw new Error("Credential lease capacity requires rotation");
764
+ }
765
+ const leaseId = crypto.randomUUID();
766
+ const lease: StoredCredentialLease = {
767
+ schemaVersion: 1,
768
+ leaseId,
769
+ effectId: input.effectId,
770
+ accountId: input.accountId,
771
+ connectionId: input.connectionId,
772
+ packageId: input.packageId,
773
+ credentialGeneration: generation,
774
+ expiresAt: input.expiresAt,
775
+ envelope: stored.envelope,
776
+ settled: false,
777
+ };
778
+ await transaction.put({
779
+ [leaseKey(input.effectId)]: lease,
780
+ [generationLeaseIndexKey]: [
781
+ ...new Set([...generationLeaseIndex, input.effectId]),
782
+ ],
783
+ [credentialKey(input.connectionId, generation)]: {
784
+ ...stored,
785
+ leaseIds: [...new Set([...stored.leaseIds, leaseId])],
786
+ } satisfies StoredCredentialGeneration,
787
+ });
788
+ await this.enqueueLease(transaction, input.effectId, expiresAt);
789
+ return this.publicLease(lease);
790
+ };
791
+ const result = storage
792
+ ? await issue(storage)
793
+ : await this.host.storage.transaction(issue);
794
+ await this.scheduleLeaseAlarm(storage);
795
+ return result;
796
+ }
797
+
798
+ async openLease(input: {
799
+ accountId: string;
800
+ packageId: string;
801
+ lease: CredentialLeaseV1;
802
+ }): Promise<string> {
803
+ const storedValue = await this.host.storage.get<unknown>(
804
+ leaseKey(input.lease.effectId),
805
+ );
806
+ const stored =
807
+ storedValue === undefined
808
+ ? undefined
809
+ : decodeStoredCredentialLease(storedValue);
810
+ if (
811
+ !stored ||
812
+ stored.accountId !== input.accountId ||
813
+ stored.packageId !== input.packageId ||
814
+ stored.leaseId !== input.lease.leaseId ||
815
+ JSON.stringify(this.publicLease(stored)) !== JSON.stringify(input.lease)
816
+ ) {
817
+ throw new Error("Credential lease is unavailable");
818
+ }
819
+ if (Date.parse(stored.expiresAt) <= this.now()) {
820
+ await this.expireLeases();
821
+ throw new Error("Credential lease expired");
822
+ }
823
+ return openCredentialV1({
824
+ keyring: this.keyring,
825
+ context: {
826
+ accountId: input.accountId,
827
+ connectionId: input.lease.connectionId,
828
+ packageId: input.packageId,
829
+ credentialGeneration: input.lease.credentialGeneration,
830
+ },
831
+ envelope: input.lease.envelope,
832
+ });
833
+ }
834
+
835
+ async settle(input: {
836
+ accountId: string;
837
+ connectionId: string;
838
+ packageId: string;
839
+ effectId: string;
840
+ }): Promise<void> {
841
+ await this.host.storage.transaction(async (storage) => {
842
+ const leaseValue = await storage.get<unknown>(leaseKey(input.effectId));
843
+ if (leaseValue === undefined) {
844
+ const tombstoneValue = await storage.get<unknown>(
845
+ leaseTombstoneKey(input.effectId),
846
+ );
847
+ if (tombstoneValue === undefined) return;
848
+ const tombstone = decodeLeaseTombstone(tombstoneValue);
849
+ if (
850
+ tombstone.accountId !== input.accountId ||
851
+ tombstone.connectionId !== input.connectionId ||
852
+ tombstone.packageId !== input.packageId
853
+ ) {
854
+ throw new Error("Credential lease authority does not match");
855
+ }
856
+ const indexKey = leaseTombstoneIndexKey(
857
+ tombstone.connectionId,
858
+ tombstone.credentialGeneration,
859
+ );
860
+ const indexValue = await storage.get<unknown>(indexKey);
861
+ const index = (
862
+ indexValue === undefined
863
+ ? []
864
+ : decodeStoredStringList(
865
+ indexValue,
866
+ "credential lease tombstone index",
867
+ )
868
+ ).filter((effectId) => effectId !== input.effectId);
869
+ await storage.delete(leaseTombstoneKey(input.effectId));
870
+ await storage.put(indexKey, index);
871
+ return;
872
+ }
873
+ const lease = decodeStoredCredentialLease(leaseValue);
874
+ if (
875
+ lease.accountId !== input.accountId ||
876
+ lease.connectionId !== input.connectionId ||
877
+ lease.packageId !== input.packageId
878
+ ) {
879
+ throw new Error("Credential lease authority does not match");
880
+ }
881
+ if (lease.settled) return;
882
+ await this.releaseLease(storage, lease, false);
883
+ });
884
+ await this.scheduleLeaseAlarm();
885
+ }
886
+
887
+ async expireLeases(now = this.now()): Promise<void> {
888
+ await this.host.storage.transaction(async (storage) => {
889
+ const stateValue = await storage.get<unknown>(LEASE_QUEUE_STATE_KEY);
890
+ if (stateValue === undefined) return;
891
+ const state = decodeStoredLeaseQueue(stateValue);
892
+ if (
893
+ state.scanPage === null &&
894
+ (state.nextAlarm === null || state.nextAlarm > now)
895
+ ) {
896
+ return;
897
+ }
898
+ const pageNumber = state.scanPage ?? state.headPage;
899
+ const pageValue = await storage.get<unknown>(
900
+ leaseQueuePageKey(pageNumber),
901
+ );
902
+ const effectIds =
903
+ pageValue === undefined ? [] : decodeLeaseQueuePage(pageValue);
904
+ const retained: string[] = [];
905
+ let minimum = state.scanMinimum;
906
+ for (const effectId of effectIds) {
907
+ const leaseValue = await storage.get<unknown>(leaseKey(effectId));
908
+ if (leaseValue === undefined) continue;
909
+ const lease = decodeStoredCredentialLease(leaseValue);
910
+ const expiry = Date.parse(lease.expiresAt);
911
+ if (expiry <= now) {
912
+ await this.releaseLease(storage, lease, true);
913
+ } else {
914
+ retained.push(effectId);
915
+ minimum = minimum === null ? expiry : Math.min(minimum, expiry);
916
+ }
917
+ }
918
+ if (retained.length === 0) {
919
+ await storage.delete(leaseQueuePageKey(pageNumber));
920
+ } else {
921
+ await storage.put(leaseQueuePageKey(pageNumber), retained);
922
+ }
923
+ if (pageNumber < state.tailPage) {
924
+ await storage.put(LEASE_QUEUE_STATE_KEY, {
925
+ ...state,
926
+ headPage:
927
+ pageNumber === state.headPage && retained.length === 0
928
+ ? state.headPage + 1
929
+ : state.headPage,
930
+ scanPage: pageNumber + 1,
931
+ scanMinimum: minimum,
932
+ } satisfies StoredLeaseQueue);
933
+ return;
934
+ }
935
+ await storage.put(LEASE_QUEUE_STATE_KEY, {
936
+ schemaVersion: 1,
937
+ headPage:
938
+ pageNumber === state.headPage && retained.length === 0
939
+ ? state.tailPage
940
+ : state.headPage,
941
+ tailPage: state.tailPage,
942
+ scanPage: null,
943
+ scanMinimum: null,
944
+ nextAlarm: minimum,
945
+ } satisfies StoredLeaseQueue);
946
+ });
947
+ await this.scheduleLeaseAlarm();
948
+ }
949
+
950
+ async nextLeaseExpiry(
951
+ storage: CredentialTransaction = this.host.storage,
952
+ ): Promise<number | undefined> {
953
+ const stateValue = await storage.get<unknown>(LEASE_QUEUE_STATE_KEY);
954
+ if (stateValue === undefined) return undefined;
955
+ const state = decodeStoredLeaseQueue(stateValue);
956
+ if (state.scanPage !== null) return this.now();
957
+ return state.nextAlarm ?? undefined;
958
+ }
959
+
960
+ private publicLease(lease: StoredCredentialLease): CredentialLeaseV1 {
961
+ const { accountId: _, packageId: __, settled: ___, ...result } = lease;
962
+ return result;
963
+ }
964
+
965
+ private async releaseLease(
966
+ storage: CredentialTransaction,
967
+ lease: StoredCredentialLease,
968
+ expired: boolean,
969
+ ): Promise<void> {
970
+ const key = credentialKey(lease.connectionId, lease.credentialGeneration);
971
+ const stored = requireGeneration(
972
+ await storage.get<unknown>(key),
973
+ lease.connectionId,
974
+ lease.credentialGeneration,
975
+ );
976
+ const next = {
977
+ ...stored,
978
+ leaseIds: stored.leaseIds.filter((id) => id !== lease.leaseId),
979
+ } satisfies StoredCredentialGeneration;
980
+ const queuePointerValue = await storage.get<unknown>(
981
+ leaseQueuePointerKey(lease.effectId),
982
+ );
983
+ if (queuePointerValue !== undefined) {
984
+ const queuePage = storedQueueNumber(
985
+ queuePointerValue,
986
+ "lease queue page",
987
+ );
988
+ if (!expired) {
989
+ const queuePageValue = await storage.get<unknown>(
990
+ leaseQueuePageKey(queuePage),
991
+ );
992
+ const queueEntries =
993
+ queuePageValue === undefined
994
+ ? []
995
+ : decodeLeaseQueuePage(queuePageValue);
996
+ const retainedQueueEntries = queueEntries.filter(
997
+ (effectId) => effectId !== lease.effectId,
998
+ );
999
+ if (retainedQueueEntries.length === 0) {
1000
+ await storage.delete(leaseQueuePageKey(queuePage));
1001
+ } else {
1002
+ await storage.put(leaseQueuePageKey(queuePage), retainedQueueEntries);
1003
+ }
1004
+ }
1005
+ await storage.delete(leaseQueuePointerKey(lease.effectId));
1006
+ }
1007
+ const generationLeaseIndexKey = leaseGenerationIndexKey(
1008
+ lease.connectionId,
1009
+ lease.credentialGeneration,
1010
+ );
1011
+ const generationLeaseIndexValue = await storage.get<unknown>(
1012
+ generationLeaseIndexKey,
1013
+ );
1014
+ const generationLeaseIndex = (
1015
+ generationLeaseIndexValue === undefined
1016
+ ? []
1017
+ : decodeStoredStringList(
1018
+ generationLeaseIndexValue,
1019
+ "credential generation lease index",
1020
+ )
1021
+ ).filter((effectId) => effectId !== lease.effectId);
1022
+ await storage.delete(leaseKey(lease.effectId));
1023
+ await storage.put(generationLeaseIndexKey, generationLeaseIndex);
1024
+ if (expired && stored.state !== "retired") {
1025
+ const tombstoneIndexKey = leaseTombstoneIndexKey(
1026
+ lease.connectionId,
1027
+ lease.credentialGeneration,
1028
+ );
1029
+ const tombstoneIndexValue = await storage.get<unknown>(tombstoneIndexKey);
1030
+ const tombstoneIndex =
1031
+ tombstoneIndexValue === undefined
1032
+ ? []
1033
+ : decodeStoredStringList(
1034
+ tombstoneIndexValue,
1035
+ "credential lease tombstone index",
1036
+ );
1037
+ await storage.put({
1038
+ [leaseTombstoneKey(lease.effectId)]: {
1039
+ accountId: lease.accountId,
1040
+ connectionId: lease.connectionId,
1041
+ packageId: lease.packageId,
1042
+ credentialGeneration: lease.credentialGeneration,
1043
+ },
1044
+ [tombstoneIndexKey]: [
1045
+ ...tombstoneIndex.filter((effectId) => effectId !== lease.effectId),
1046
+ lease.effectId,
1047
+ ],
1048
+ });
1049
+ }
1050
+ if (next.state === "retired" && next.leaseIds.length === 0) {
1051
+ await storage.delete(key);
1052
+ await storage.delete(generationLeaseIndexKey);
1053
+ await storage.delete(
1054
+ leaseTombstoneIndexKey(lease.connectionId, lease.credentialGeneration),
1055
+ );
1056
+ } else {
1057
+ await storage.put(key, next);
1058
+ }
1059
+ }
1060
+
1061
+ private async scheduleLeaseAlarm(
1062
+ storage: CredentialTransaction = this.host.storage,
1063
+ ): Promise<void> {
1064
+ if (!storage.setAlarm) return;
1065
+ const next = await this.nextLeaseExpiry(storage);
1066
+ if (next === undefined) return;
1067
+ const current = await storage.getAlarm?.();
1068
+ if (
1069
+ next <= this.now() ||
1070
+ current === null ||
1071
+ current === undefined ||
1072
+ next < current
1073
+ ) {
1074
+ await storage.setAlarm(next);
1075
+ }
1076
+ }
1077
+
1078
+ async disconnect(connectionId: string): Promise<void> {
1079
+ await this.host.storage.transaction(async (storage) => {
1080
+ const generationValue = await storage.get<unknown>(
1081
+ activeKey(connectionId),
1082
+ );
1083
+ if (generationValue === undefined) return;
1084
+ const generation = decodeStoredGenerationId(generationValue);
1085
+ const key = credentialKey(connectionId, generation);
1086
+ const stored = requireGeneration(
1087
+ await storage.get<unknown>(key),
1088
+ connectionId,
1089
+ generation,
1090
+ );
1091
+ const tombstoneIndexKey = leaseTombstoneIndexKey(
1092
+ connectionId,
1093
+ generation,
1094
+ );
1095
+ const tombstoneIndexValue = await storage.get<unknown>(tombstoneIndexKey);
1096
+ const tombstoneIndex =
1097
+ tombstoneIndexValue === undefined
1098
+ ? []
1099
+ : decodeStoredStringList(
1100
+ tombstoneIndexValue,
1101
+ "credential lease tombstone index",
1102
+ );
1103
+ for (const effectId of tombstoneIndex) {
1104
+ const tombstoneValue = await storage.get<unknown>(
1105
+ leaseTombstoneKey(effectId),
1106
+ );
1107
+ if (tombstoneValue === undefined) continue;
1108
+ const tombstone = decodeLeaseTombstone(tombstoneValue);
1109
+ if (
1110
+ tombstone.accountId !== stored.accountId ||
1111
+ tombstone.connectionId !== connectionId ||
1112
+ tombstone.packageId !== stored.packageId ||
1113
+ tombstone.credentialGeneration !== generation
1114
+ ) {
1115
+ throw new Error("Credential lease tombstone index is invalid");
1116
+ }
1117
+ await storage.delete(leaseTombstoneKey(effectId));
1118
+ }
1119
+ await storage.delete(tombstoneIndexKey);
1120
+ await storage.delete(activeKey(connectionId));
1121
+ if (stored.leaseIds.length === 0) {
1122
+ await storage.delete(key);
1123
+ await storage.delete(leaseGenerationIndexKey(connectionId, generation));
1124
+ } else {
1125
+ await storage.put(key, { ...stored, state: "retired" });
1126
+ }
1127
+ });
1128
+ }
1129
+ }
1130
+
1131
+ export function createCredentialUserBackendContribution(
1132
+ host: CredentialUserBackendHost,
1133
+ ): CredentialUserBackendContribution {
1134
+ return new CredentialUserBackendContribution(host);
1135
+ }
1136
+
1137
+ export function createCredentialUserBackendPlugin(
1138
+ host: CredentialUserBackendHost,
1139
+ lifecycle: { mount(value: CredentialUserBackendContribution): () => void },
1140
+ ): Plugin {
1141
+ return () => lifecycle.mount(createCredentialUserBackendContribution(host));
1142
+ }