@kubun/store-credential 0.14.0 → 0.15.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/lib/api.d.ts CHANGED
@@ -1,6 +1,54 @@
1
1
  import type { Adapter } from '@kubun/db-adapter';
2
2
  import type { Kysely } from 'kysely';
3
- import type { CredentialEntry, CredentialEntryWithProvenance, CredentialFactorDescriptor, CredentialKey, CredentialKeyBranch, CredentialKeyState, CredentialOp, CredentialOpSubjectKind, CredentialTables, CredentialWrapping, CredentialWrappingWithBranch } from './tables.js';
3
+ import type { CredentialEntry, CredentialEntryWithProvenance, CredentialFactorDescriptor, CredentialKey, CredentialKeyBranch, CredentialKeyState, CredentialManifestKey, CredentialOp, CredentialOpSubjectKind, CredentialTables, CredentialWrapping, CredentialWrappingWithBranch } from './tables.js';
4
+ /**
5
+ * The persisted half of a credential manifest — a structural subset of the
6
+ * plugin's signed `CredentialManifestClaim`, so a verified claim can pass
7
+ * straight to {@link CredentialStoreAPI.putManifest} without mapping. This
8
+ * package neither signs nor verifies; the raw token rides alongside as `token`.
9
+ */
10
+ export type CredentialManifestRecord = {
11
+ ownerDID: string;
12
+ recipientDID: string;
13
+ grantorDID: string;
14
+ epoch: number;
15
+ sequence: number;
16
+ keys: Array<CredentialManifestKey>;
17
+ };
18
+ export type ListManifestsParams = {
19
+ ownerDID: string;
20
+ recipientDID: string;
21
+ };
22
+ /** One stored manifest: its persisted record, the signed token that authored it, and delegation tokens. */
23
+ export type StoredCredentialManifest = {
24
+ record: CredentialManifestRecord;
25
+ token: string;
26
+ delegationTokens: Array<string>;
27
+ };
28
+ export type PutWrappingAuthorityParams = {
29
+ wrappingID: string;
30
+ envelope: string;
31
+ refreshSeq: number;
32
+ };
33
+ export type WrappingAuthority = {
34
+ envelope: string;
35
+ refreshSeq: number;
36
+ };
37
+ export type NextGrantorSequenceParams = {
38
+ ownerDID: string;
39
+ recipientDID: string;
40
+ grantorDID: string;
41
+ };
42
+ export type NextProvisioningEpochParams = {
43
+ ownerDID: string;
44
+ recipientDID: string;
45
+ grantorDID: string;
46
+ minEpoch: number;
47
+ };
48
+ export type ListDistributableKeysParams = {
49
+ ownerDID: string;
50
+ grantorDID: string;
51
+ };
4
52
  /**
5
53
  * The signed op that produced a row's current state, as the store holds it.
6
54
  *
@@ -77,6 +125,13 @@ export type HeldCredentialSubject = {
77
125
  * query context passes it today: it holds a viewer's DID and no signing key.
78
126
  */
79
127
  export type CredentialStoreAPI = {
128
+ /**
129
+ * Run `fn` against a `CredentialStoreAPI` scoped to one atomic transaction.
130
+ * Nesting-safe: if `this` is already bound to an open Kysely transaction,
131
+ * `fn` runs inline against `this` instead of opening a second one (Kysely
132
+ * has no nested transactions), so callers can invoke this unconditionally.
133
+ */
134
+ transaction<R>(fn: (store: CredentialStoreAPI) => Promise<R>): Promise<R>;
80
135
  putKey(params: PutCredentialKeyParams, op: CredentialOpInput | null): Promise<void>;
81
136
  getKey(keyID: string): Promise<CredentialKey | undefined>;
82
137
  listKeys(ownerDID: string): Promise<Array<CredentialKey>>;
@@ -94,7 +149,17 @@ export type CredentialStoreAPI = {
94
149
  branchID: string;
95
150
  }): Promise<void>;
96
151
  listKeyBranches(keyID: string, keyVersion: number): Promise<Array<CredentialKeyBranch>>;
97
- putWrapping(params: PutCredentialWrappingParams, op: CredentialOpInput | null): Promise<void>;
152
+ /**
153
+ * `authority`, when present, upserts the wrapping's grantor-authority envelope
154
+ * in the SAME transaction as the wrapping row and its op, so a crash can't
155
+ * leave a held wrapping this device can't later prove was authorized. Omitted
156
+ * for an owner-signed wrapping. Gated strictly-greater on `refreshSeq`, same
157
+ * as {@link CredentialStoreAPI.putWrappingAuthority}.
158
+ */
159
+ putWrapping(params: PutCredentialWrappingParams, op: CredentialOpInput | null, authority?: {
160
+ envelope: string;
161
+ refreshSeq: number;
162
+ }): Promise<void>;
98
163
  getWrapping(wrappingID: string): Promise<CredentialWrapping | undefined>;
99
164
  /** Every wrapping for a key, or only those at one version. */
100
165
  listWrappings(keyID: string, keyVersion?: number): Promise<Array<CredentialWrapping>>;
@@ -165,5 +230,120 @@ export type CredentialStoreAPI = {
165
230
  setAliasEntryID(namespace: string, key: string, entryID: string): Promise<void>;
166
231
  /** Clear an alias. A no-op, not an error, when none is set. */
167
232
  deleteAliasEntryID(namespace: string, key: string): Promise<void>;
233
+ /**
234
+ * The distinct keys currently set within `namespace`. Keys stay opaque here —
235
+ * a caller interprets them (e.g. connector credentials store the normalized
236
+ * owner DID as the key under a provider namespace). Empty for an unused
237
+ * namespace.
238
+ */
239
+ listAliasKeys(namespace: string): Promise<Array<string>>;
240
+ /** Store a wrapping's grantor-authority envelope; gated strictly-greater on `refreshSeq` (stale/replayed is a silent no-op). */
241
+ putWrappingAuthority(params: PutWrappingAuthorityParams): Promise<void>;
242
+ /** The stored envelope and its `refreshSeq`, or `undefined` when unset. */
243
+ getWrappingAuthority(wrappingID: string): Promise<WrappingAuthority | undefined>;
244
+ /**
245
+ * Mint the next `refreshSeq` for one `(ownerDID, recipientDID, grantorDID)`
246
+ * triple: 1 on the first call, strictly increasing thereafter. Persisted, so
247
+ * a restart never reissues a number already handed out.
248
+ */
249
+ nextGrantorSequence(params: NextGrantorSequenceParams): Promise<number>;
250
+ /**
251
+ * Allocate the next `epoch` for one `(ownerDID, recipientDID, grantorDID)`
252
+ * triple: returns `max(stored, minEpoch) + 1` on each call, so the value is
253
+ * strictly increasing but a floor can jump it forward. Persisted; never
254
+ * reissues an epoch already handed out. Triple is keyed on normalized DIDs,
255
+ * so a `did:peer:4` short/long form converge on one row.
256
+ */
257
+ nextProvisioningEpoch(params: NextProvisioningEpochParams): Promise<number>;
258
+ /**
259
+ * Store a grantor's manifest, gated lexicographically on `(epoch, sequence)`
260
+ * per `(ownerDID, recipientDID, grantorDID)` triple — higher epoch always
261
+ * wins, sequence breaks ties within an epoch; a stale/replayed value is a
262
+ * silent no-op. Triple keyed on normalized DIDs. `token` is the raw signed
263
+ * manifest; `delegationTokens` are optional wrappings that authorized it.
264
+ */
265
+ putManifest(record: CredentialManifestRecord, token: string, delegationTokens?: Array<string>): Promise<void>;
266
+ /**
267
+ * Every grantor's manifest for one `(ownerDID, recipientDID)`, one row per
268
+ * grantor. Arguments are normalized before query, matching
269
+ * {@link CredentialStoreAPI.putManifest}'s normalize-on-write.
270
+ */
271
+ listManifests(params: ListManifestsParams): Promise<Array<StoredCredentialManifest>>;
272
+ /**
273
+ * Take the write lock on the manifest row so a held-epoch read later in the
274
+ * SAME transaction can't race a concurrent {@link CredentialStoreAPI.putManifest}.
275
+ * A value-preserving UPDATE, not `FOR UPDATE` (SQLite rejects that syntax) —
276
+ * it blocks a concurrent Postgres writer the same way; SQLite is already
277
+ * single-writer-serialized. No-op if the row doesn't exist yet; that
278
+ * first-write race is benign (held epoch 0).
279
+ */
280
+ lockManifestRow(params: {
281
+ ownerDID: string;
282
+ recipientDID: string;
283
+ grantorDID: string;
284
+ }): Promise<void>;
285
+ /**
286
+ * Every active owner key for which a wrapping exists at the key's current
287
+ * version addressed (after normalization) to the grantor. One snapshot read;
288
+ * in-memory DID normalization. Deduped by keyID.
289
+ */
290
+ listDistributableKeys(params: ListDistributableKeysParams): Promise<Array<{
291
+ keyID: string;
292
+ version: number;
293
+ }>>;
294
+ /**
295
+ * Begin tracking a provisioning attempt, idempotent on the id. Ensures an
296
+ * expectation floor row exists (at 0) if none yet. The attempt counts toward
297
+ * outstanding and blocks readiness until cleared.
298
+ */
299
+ beginProvisioningAttempt(params: {
300
+ ownerDID: string;
301
+ grantorDID: string;
302
+ attemptId: string;
303
+ }): Promise<void>;
304
+ /**
305
+ * Record a provisioning attempt's outcome, atomically. If `epoch > heldEpoch`,
306
+ * clears the attempt row and raises the expectation floor to
307
+ * `max(stored, epoch)`; otherwise leaves the attempt row and returns retry.
308
+ */
309
+ recordProvisioningAttempt(params: {
310
+ ownerDID: string;
311
+ grantorDID: string;
312
+ attemptId: string;
313
+ epoch: number;
314
+ heldEpoch: number;
315
+ }): Promise<{
316
+ status: 'recorded';
317
+ } | {
318
+ status: 'retry';
319
+ minEpoch: number;
320
+ }>;
321
+ /**
322
+ * Abandon a provisioning attempt. Deletes ONLY the attempt row, never touches
323
+ * the expectation floor.
324
+ */
325
+ abandonProvisioningAttempt(params: {
326
+ ownerDID: string;
327
+ grantorDID: string;
328
+ attemptId: string;
329
+ }): Promise<void>;
330
+ /**
331
+ * List provisioning expectations for one owner: one row per (owner, grantor)
332
+ * pair currently held. Returns the expectation floor and count of outstanding
333
+ * attempts. Normalizes ownerDID on read.
334
+ */
335
+ listProvisioningExpectations(params: {
336
+ ownerDID: string;
337
+ }): Promise<Array<{
338
+ grantorDID: string;
339
+ epochFloor: number;
340
+ outstanding: number;
341
+ }>>;
342
+ /**
343
+ * Every distinct owner DID with a provisioning expectation row, normalized
344
+ * and de-duped — the catalog-independent enumeration
345
+ * `listProvisioningExpectations` can't give on its own (it's per-owner).
346
+ */
347
+ listProvisioningExpectationOwners(): Promise<Array<string>>;
168
348
  };
169
349
  export declare function createCredentialStore(db: Kysely<CredentialTables>, adapter: Adapter): CredentialStoreAPI;
package/lib/api.js CHANGED
@@ -16,6 +16,21 @@ import { normalizeDID } from '@kokuin/token';
16
16
  await write(tx);
17
17
  });
18
18
  }
19
+ /**
20
+ * The strictly-greater-`refreshSeq` upsert shared by {@link CredentialStoreAPI.putWrappingAuthority}
21
+ * and `putWrapping`'s own-transaction variant — one predicate, so the two entry
22
+ * points cannot drift on what "newer" means.
23
+ */ async function upsertWrappingAuthority(db, params) {
24
+ const values = {
25
+ wrapping_id: params.wrappingID,
26
+ authority_token: params.envelope,
27
+ refresh_seq: params.refreshSeq
28
+ };
29
+ await db.insertInto('kubun_credential_wrapping_authority').values(values).onConflict((oc)=>oc.column('wrapping_id').doUpdateSet({
30
+ authority_token: values.authority_token,
31
+ refresh_seq: values.refresh_seq
32
+ }).where('kubun_credential_wrapping_authority.refresh_seq', '<', values.refresh_seq)).execute();
33
+ }
19
34
  const OP_REJECTED = Symbol('credential op rejected');
20
35
  async function writeOp(params) {
21
36
  const { db, subjectKind, subjectID, op, deleted, allowKeyMove = false } = params;
@@ -153,7 +168,13 @@ export function createCredentialStore(db, adapter) {
153
168
  allowKeyMove: allowMove
154
169
  });
155
170
  }
156
- return {
171
+ const api = {
172
+ async transaction (fn) {
173
+ if (db.isTransaction) {
174
+ return await fn(api);
175
+ }
176
+ return await db.transaction().execute(async (tx)=>await fn(createCredentialStore(tx, adapter)));
177
+ },
157
178
  async putKey (params, op) {
158
179
  await writeSubject({
159
180
  subjectKind: 'key',
@@ -241,7 +262,7 @@ export function createCredentialStore(db, adapter) {
241
262
  async listKeyBranches (keyID, keyVersion) {
242
263
  return await db.selectFrom('kubun_credential_key_branches').selectAll().where('key_id', '=', keyID).where('key_version', '=', keyVersion).orderBy('branch_id', 'asc').execute();
243
264
  },
244
- async putWrapping (params, op) {
265
+ async putWrapping (params, op, authority) {
245
266
  const values = {
246
267
  wrapping_id: params.wrappingID,
247
268
  key_id: params.keyID,
@@ -271,6 +292,15 @@ export function createCredentialStore(db, adapter) {
271
292
  if ((result.numInsertedOrUpdatedRows ?? 0n) === 0n) {
272
293
  throw new Error(`Credential wrapping ${params.wrappingID} belongs to another key`);
273
294
  }
295
+ // Runs only after `writeOp` wins its LWW race, so a superseded
296
+ // wrapping never persists an envelope it didn't actually establish.
297
+ if (authority != null) {
298
+ await upsertWrappingAuthority(tx, {
299
+ wrappingID: params.wrappingID,
300
+ envelope: authority.envelope,
301
+ refreshSeq: authority.refreshSeq
302
+ });
303
+ }
274
304
  }
275
305
  });
276
306
  },
@@ -428,8 +458,241 @@ export function createCredentialStore(db, adapter) {
428
458
  },
429
459
  async deleteAliasEntryID (namespace, key) {
430
460
  await db.deleteFrom('kubun_credential_aliases').where('namespace', '=', namespace).where('alias_key', '=', key).execute();
461
+ },
462
+ async listAliasKeys (namespace) {
463
+ const rows = await db.selectFrom('kubun_credential_aliases').select('alias_key').distinct().where('namespace', '=', namespace).execute();
464
+ return rows.map((row)=>row.alias_key);
465
+ },
466
+ async putWrappingAuthority (params) {
467
+ await upsertWrappingAuthority(db, params);
468
+ },
469
+ async getWrappingAuthority (wrappingID) {
470
+ const row = await db.selectFrom('kubun_credential_wrapping_authority').select([
471
+ 'authority_token',
472
+ 'refresh_seq'
473
+ ]).where('wrapping_id', '=', wrappingID).executeTakeFirst();
474
+ if (row == null) return undefined;
475
+ return {
476
+ envelope: row.authority_token,
477
+ refreshSeq: row.refresh_seq
478
+ };
479
+ },
480
+ async nextGrantorSequence (params) {
481
+ // Atomic INSERT ... ON CONFLICT DO UPDATE increments in place, so two
482
+ // concurrent callers can't observe the same seq. Normalize the tuple
483
+ // HERE (not left to callers) so a did:peer:4 recipient's short/long
484
+ // forms converge on one counter instead of splitting into two.
485
+ const row = await db.insertInto('kubun_credential_grantor_counters').values({
486
+ owner_did: normalizeDID(params.ownerDID),
487
+ recipient_did: normalizeDID(params.recipientDID),
488
+ grantor_did: normalizeDID(params.grantorDID),
489
+ seq: 1
490
+ }).onConflict((oc)=>oc.columns([
491
+ 'owner_did',
492
+ 'recipient_did',
493
+ 'grantor_did'
494
+ ]).doUpdateSet((eb)=>({
495
+ seq: eb('kubun_credential_grantor_counters.seq', '+', 1)
496
+ }))).returning('seq').executeTakeFirstOrThrow();
497
+ return row.seq;
498
+ },
499
+ async nextProvisioningEpoch (params) {
500
+ // Atomic upsert to max(stored, minEpoch) + 1, guaranteeing monotonicity.
501
+ // Normalize the tuple HERE, same reasoning as `nextGrantorSequence`.
502
+ const ownerDid = normalizeDID(params.ownerDID);
503
+ const recipientDid = normalizeDID(params.recipientDID);
504
+ const grantorDid = normalizeDID(params.grantorDID);
505
+ const floor = Math.max(0, params.minEpoch);
506
+ const row = await db.insertInto('kubun_credential_provisioning_epochs').values({
507
+ owner_did: ownerDid,
508
+ recipient_did: recipientDid,
509
+ grantor_did: grantorDid,
510
+ epoch: floor + 1
511
+ }).onConflict((oc)=>oc.columns([
512
+ 'owner_did',
513
+ 'recipient_did',
514
+ 'grantor_did'
515
+ ]).doUpdateSet((eb)=>({
516
+ // max(stored, floor) + 1, expressed without a portable GREATEST:
517
+ epoch: eb.case().when('kubun_credential_provisioning_epochs.epoch', '<', floor).then(floor + 1).else(eb('kubun_credential_provisioning_epochs.epoch', '+', 1)).end()
518
+ }))).returning('epoch').executeTakeFirstOrThrow();
519
+ return row.epoch;
520
+ },
521
+ async putManifest (record, token, delegationTokens) {
522
+ const values = {
523
+ owner_did: normalizeDID(record.ownerDID),
524
+ recipient_did: normalizeDID(record.recipientDID),
525
+ grantor_did: normalizeDID(record.grantorDID),
526
+ epoch: record.epoch,
527
+ sequence: record.sequence,
528
+ keys_json: adapter.encodeJSON(record.keys),
529
+ manifest_token: token,
530
+ delegation_tokens: adapter.encodeJSON(delegationTokens ?? [])
531
+ };
532
+ await db.insertInto('kubun_credential_manifests').values(values).onConflict((oc)=>oc.columns([
533
+ 'owner_did',
534
+ 'recipient_did',
535
+ 'grantor_did'
536
+ ]).doUpdateSet({
537
+ epoch: values.epoch,
538
+ sequence: values.sequence,
539
+ keys_json: values.keys_json,
540
+ manifest_token: values.manifest_token,
541
+ delegation_tokens: values.delegation_tokens
542
+ })// Lexicographic: higher epoch always wins; within an epoch,
543
+ // strictly-greater sequence. A stale/replayed lower value is a silent no-op.
544
+ .where((eb)=>eb.or([
545
+ eb('kubun_credential_manifests.epoch', '<', values.epoch),
546
+ eb.and([
547
+ eb('kubun_credential_manifests.epoch', '=', values.epoch),
548
+ eb('kubun_credential_manifests.sequence', '<', values.sequence)
549
+ ])
550
+ ]))).execute();
551
+ },
552
+ async lockManifestRow (params) {
553
+ const owner = normalizeDID(params.ownerDID);
554
+ const recipient = normalizeDID(params.recipientDID);
555
+ const grantor = normalizeDID(params.grantorDID);
556
+ await writeAtomically(db, async (tx)=>{
557
+ await tx.updateTable('kubun_credential_manifests').set((eb)=>({
558
+ epoch: eb.ref('kubun_credential_manifests.epoch')
559
+ })).where('owner_did', '=', owner).where('recipient_did', '=', recipient).where('grantor_did', '=', grantor).execute();
560
+ });
561
+ },
562
+ async listManifests (params) {
563
+ const ownerDID = normalizeDID(params.ownerDID);
564
+ const recipientDID = normalizeDID(params.recipientDID);
565
+ const rows = await db.selectFrom('kubun_credential_manifests').select([
566
+ 'owner_did',
567
+ 'recipient_did',
568
+ 'grantor_did',
569
+ 'epoch',
570
+ 'sequence',
571
+ 'keys_json',
572
+ 'manifest_token',
573
+ 'delegation_tokens'
574
+ ]).where('owner_did', '=', ownerDID).where('recipient_did', '=', recipientDID).orderBy('grantor_did', 'asc').execute();
575
+ return rows.map((row)=>({
576
+ record: {
577
+ ownerDID: row.owner_did,
578
+ recipientDID: row.recipient_did,
579
+ grantorDID: row.grantor_did,
580
+ epoch: row.epoch,
581
+ sequence: row.sequence,
582
+ keys: row.keys_json
583
+ },
584
+ token: row.manifest_token,
585
+ delegationTokens: row.delegation_tokens
586
+ }));
587
+ },
588
+ async listDistributableKeys (params) {
589
+ const owner = normalizeDID(params.ownerDID);
590
+ const grantor = normalizeDID(params.grantorDID);
591
+ // One snapshot: active owner keys joined to wrappings at the key's current version.
592
+ const rows = await db.selectFrom('kubun_credential_keys as k').innerJoin('kubun_credential_wrappings as w', (join)=>join.onRef('w.key_id', '=', 'k.key_id').onRef('w.key_version', '=', 'k.version')).where('k.state', '=', 'active').select([
593
+ 'k.key_id as keyID',
594
+ 'k.version as version',
595
+ 'k.owner_did as ownerDID',
596
+ 'w.recipient_did as recipientDID'
597
+ ]).execute();
598
+ const seen = new Set();
599
+ const out = [];
600
+ for (const row of rows){
601
+ if (normalizeDID(row.ownerDID) !== owner) continue;
602
+ if (row.recipientDID == null || normalizeDID(row.recipientDID) !== grantor) continue;
603
+ if (seen.has(row.keyID)) continue;
604
+ seen.add(row.keyID);
605
+ out.push({
606
+ keyID: row.keyID,
607
+ version: row.version
608
+ });
609
+ }
610
+ return out;
611
+ },
612
+ async beginProvisioningAttempt (params) {
613
+ const ownerDid = normalizeDID(params.ownerDID);
614
+ const grantorDid = normalizeDID(params.grantorDID);
615
+ await writeAtomically(db, async (tx)=>{
616
+ // Ensure the expectation floor row exists, idempotent at floor 0.
617
+ await tx.insertInto('kubun_credential_provisioning_expectations').values({
618
+ owner_did: ownerDid,
619
+ grantor_did: grantorDid,
620
+ epoch_floor: 0
621
+ }).onConflict((oc)=>oc.doNothing()).execute();
622
+ // Insert the outstanding attempt row, idempotent on the id.
623
+ await tx.insertInto('kubun_credential_provisioning_attempts').values({
624
+ owner_did: ownerDid,
625
+ grantor_did: grantorDid,
626
+ attempt_id: params.attemptId
627
+ }).onConflict((oc)=>oc.doNothing()).execute();
628
+ });
629
+ },
630
+ async recordProvisioningAttempt (params) {
631
+ const ownerDid = normalizeDID(params.ownerDID);
632
+ const grantorDid = normalizeDID(params.grantorDID);
633
+ // Single atomic operation: if epoch > heldEpoch, delete attempt and raise floor.
634
+ let result = {
635
+ status: 'retry',
636
+ minEpoch: params.heldEpoch
637
+ };
638
+ await writeAtomically(db, async (tx)=>{
639
+ if (params.epoch > params.heldEpoch) {
640
+ // Phantom-attempt guard: if the id names no live attempt, move
641
+ // nothing — raising the floor for it would wedge readiness forever.
642
+ const existing = await tx.selectFrom('kubun_credential_provisioning_attempts').select('attempt_id').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptId).executeTakeFirst();
643
+ if (existing == null) {
644
+ return;
645
+ }
646
+ // Delete the attempt row.
647
+ await tx.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptId).execute();
648
+ // Raise the floor to max(stored, epoch).
649
+ await tx.updateTable('kubun_credential_provisioning_expectations').set((eb)=>({
650
+ epoch_floor: eb.case().when('epoch_floor', '<', params.epoch).then(params.epoch).else(eb.ref('epoch_floor')).end()
651
+ })).where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).execute();
652
+ result = {
653
+ status: 'recorded'
654
+ };
655
+ }
656
+ });
657
+ return result;
658
+ },
659
+ async abandonProvisioningAttempt (params) {
660
+ const ownerDid = normalizeDID(params.ownerDID);
661
+ const grantorDid = normalizeDID(params.grantorDID);
662
+ // Delete ONLY the attempt row, never the floor.
663
+ await db.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptId).execute();
664
+ },
665
+ async listProvisioningExpectations (params) {
666
+ const ownerDid = normalizeDID(params.ownerDID);
667
+ // Left join expectations to outstanding attempts, grouped by (owner, grantor).
668
+ // Return one row per (owner, grantor) with floor and outstanding count.
669
+ const rows = await db.selectFrom('kubun_credential_provisioning_expectations as e').leftJoin('kubun_credential_provisioning_attempts as a', (join)=>join.onRef('a.owner_did', '=', 'e.owner_did').onRef('a.grantor_did', '=', 'e.grantor_did')).select((eb)=>[
670
+ 'e.grantor_did as grantor_did',
671
+ 'e.epoch_floor as epoch_floor',
672
+ eb.fn.count('a.attempt_id').as('outstanding')
673
+ ]).where('e.owner_did', '=', ownerDid).groupBy([
674
+ 'e.owner_did',
675
+ 'e.grantor_did',
676
+ 'e.epoch_floor'
677
+ ]).orderBy('e.grantor_did', 'asc').execute();
678
+ return rows.map((row)=>({
679
+ grantorDID: row.grantor_did,
680
+ epochFloor: row.epoch_floor,
681
+ outstanding: Number(row.outstanding)
682
+ }));
683
+ },
684
+ async listProvisioningExpectationOwners () {
685
+ const rows = await db.selectFrom('kubun_credential_provisioning_expectations').select('owner_did').distinct().execute();
686
+ const owners = new Set();
687
+ for (const row of rows){
688
+ owners.add(normalizeDID(row.owner_did));
689
+ }
690
+ return [
691
+ ...owners
692
+ ];
431
693
  }
432
694
  };
695
+ return api;
433
696
  }
434
697
  /**
435
698
  * The ops leaf scan in stamp order — every one, or one key's. Two devices must
package/lib/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { StoreProvider } from '@kubun/db';
2
2
  import type { CredentialStoreAPI } from './api.js';
3
- export type { CredentialOpInput, CredentialStoreAPI, CredentialTombstoneInput, HeldCredentialSubject, PutCredentialEntryParams, PutCredentialKeyParams, PutCredentialWrappingParams, } from './api.js';
3
+ export type { CredentialManifestRecord, CredentialOpInput, CredentialStoreAPI, CredentialTombstoneInput, HeldCredentialSubject, ListManifestsParams, NextGrantorSequenceParams, PutCredentialEntryParams, PutCredentialKeyParams, PutCredentialWrappingParams, PutWrappingAuthorityParams, StoredCredentialManifest, WrappingAuthority, } from './api.js';
4
4
  export { createCredentialStore } from './api.js';
5
5
  export { credentialStoreDefinition } from './definition.js';
6
6
  export declare const CREDENTIAL_STORE: "credential";
7
7
  export declare function getCredentialStore(provider: StoreProvider): Promise<CredentialStoreAPI>;
8
- export type { CredentialAlias, CredentialEntry, CredentialEntryTable, CredentialEntryWithProvenance, CredentialFactorDescriptor, CredentialKey, CredentialKeyBranch, CredentialKeyBranchTable, CredentialKeyState, CredentialKeyTable, CredentialOp, CredentialOpSubjectKind, CredentialOpTable, CredentialTables, CredentialWrapping, CredentialWrappingTable, CredentialWrappingWithBranch, InsertCredentialAlias, InsertCredentialEntry, InsertCredentialKey, InsertCredentialOp, InsertCredentialWrapping, KubunCredentialAliasTable, } from './tables.js';
8
+ export type { CredentialAlias, CredentialEntry, CredentialEntryTable, CredentialEntryWithProvenance, CredentialFactorDescriptor, CredentialGrantorCounter, CredentialGrantorCounterTable, CredentialKey, CredentialKeyBranch, CredentialKeyBranchTable, CredentialKeyState, CredentialKeyTable, CredentialManifest, CredentialManifestKey, CredentialManifestTable, CredentialOp, CredentialOpSubjectKind, CredentialOpTable, CredentialTables, CredentialWrapping, CredentialWrappingAuthority, CredentialWrappingAuthorityTable, CredentialWrappingTable, CredentialWrappingWithBranch, InsertCredentialAlias, InsertCredentialEntry, InsertCredentialGrantorCounter, InsertCredentialKey, InsertCredentialManifest, InsertCredentialOp, InsertCredentialWrapping, InsertCredentialWrappingAuthority, KubunCredentialAliasTable, } from './tables.js';
package/lib/migrations.js CHANGED
@@ -49,8 +49,45 @@ export function getCredentialMigrations(ctx) {
49
49
  'namespace',
50
50
  'alias_key'
51
51
  ]).execute();
52
+ // Grantor-authority envelope for a wrapping; `refresh_seq` gates every upsert strictly-greater.
53
+ await db.schema.createTable('kubun_credential_wrapping_authority').ifNotExists().addColumn('wrapping_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('authority_token', t.text, (col)=>col.notNull()).addColumn('refresh_seq', 'integer', (col)=>col.notNull()).execute();
54
+ // Persisted counter minting strictly-increasing refreshSeq, one per (owner, recipient, grantor) triple.
55
+ await db.schema.createTable('kubun_credential_grantor_counters').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('recipient_did', t.text, (col)=>col.notNull()).addColumn('grantor_did', t.text, (col)=>col.notNull()).addColumn('seq', 'integer', (col)=>col.notNull()).addPrimaryKeyConstraint('pk_kubun_credential_grantor_counters', [
56
+ 'owner_did',
57
+ 'recipient_did',
58
+ 'grantor_did'
59
+ ]).execute();
60
+ // Persisted counter minting strictly-increasing provisioning epoch, respecting a minEpoch floor.
61
+ await db.schema.createTable('kubun_credential_provisioning_epochs').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('recipient_did', t.text, (col)=>col.notNull()).addColumn('grantor_did', t.text, (col)=>col.notNull()).addColumn('epoch', 'integer', (col)=>col.notNull()).addPrimaryKeyConstraint('pk_kubun_credential_provisioning_epochs', [
62
+ 'owner_did',
63
+ 'recipient_did',
64
+ 'grantor_did'
65
+ ]).execute();
66
+ // Provisioning expectation floor per (owner, grantor) pair; normalized DIDs.
67
+ await db.schema.createTable('kubun_credential_provisioning_expectations').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('grantor_did', t.text, (col)=>col.notNull()).addColumn('epoch_floor', 'integer', (col)=>col.notNull().defaultTo(0)).addPrimaryKeyConstraint('pk_kubun_credential_provisioning_expectations', [
68
+ 'owner_did',
69
+ 'grantor_did'
70
+ ]).execute();
71
+ // Outstanding provisioning attempts; row exists while in flight, deleted on completion.
72
+ await db.schema.createTable('kubun_credential_provisioning_attempts').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('grantor_did', t.text, (col)=>col.notNull()).addColumn('attempt_id', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_kubun_credential_provisioning_attempts', [
73
+ 'owner_did',
74
+ 'grantor_did',
75
+ 'attempt_id'
76
+ ]).execute();
77
+ // Grantor-recorded manifests, keyed on the NORMALIZED triple; `(epoch, sequence)` gate every upsert lexicographically.
78
+ await db.schema.createTable('kubun_credential_manifests').ifNotExists().addColumn('owner_did', t.text, (col)=>col.notNull()).addColumn('recipient_did', t.text, (col)=>col.notNull()).addColumn('grantor_did', t.text, (col)=>col.notNull()).addColumn('epoch', 'integer', (col)=>col.notNull()).addColumn('sequence', 'integer', (col)=>col.notNull()).addColumn('keys_json', t.json, (col)=>col.notNull()).addColumn('manifest_token', t.text, (col)=>col.notNull()).addColumn('delegation_tokens', t.json, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_kubun_credential_manifests', [
79
+ 'owner_did',
80
+ 'recipient_did',
81
+ 'grantor_did'
82
+ ]).execute();
52
83
  },
53
84
  async down (db) {
85
+ await db.schema.dropTable('kubun_credential_manifests').ifExists().execute();
86
+ await db.schema.dropTable('kubun_credential_provisioning_attempts').ifExists().execute();
87
+ await db.schema.dropTable('kubun_credential_provisioning_expectations').ifExists().execute();
88
+ await db.schema.dropTable('kubun_credential_provisioning_epochs').ifExists().execute();
89
+ await db.schema.dropTable('kubun_credential_grantor_counters').ifExists().execute();
90
+ await db.schema.dropTable('kubun_credential_wrapping_authority').ifExists().execute();
54
91
  await db.schema.dropTable('kubun_credential_aliases').ifExists().execute();
55
92
  await db.schema.dropTable('kubun_credential_ops').ifExists().execute();
56
93
  await db.schema.dropTable('kubun_credential_entries').ifExists().execute();
package/lib/tables.d.ts CHANGED
@@ -166,6 +166,99 @@ export type KubunCredentialAliasTable = {
166
166
  };
167
167
  export type CredentialAlias = Selectable<KubunCredentialAliasTable>;
168
168
  export type InsertCredentialAlias = Insertable<KubunCredentialAliasTable>;
169
+ /**
170
+ * The grantor-authority envelope backing one wrapping — a signed token proving
171
+ * whoever minted the wrapping held the right to grant it, carried beside it
172
+ * rather than folded into its content-addressed digest. `refresh_seq` gates
173
+ * every `put` strictly-greater, so a stale/replayed envelope is a silent no-op.
174
+ */
175
+ export type CredentialWrappingAuthorityTable = {
176
+ wrapping_id: string;
177
+ authority_token: string;
178
+ refresh_seq: number;
179
+ };
180
+ export type CredentialWrappingAuthority = Selectable<CredentialWrappingAuthorityTable>;
181
+ export type InsertCredentialWrappingAuthority = Insertable<CredentialWrappingAuthorityTable>;
182
+ /**
183
+ * Monotonic counter minting the strictly-increasing `refreshSeq`, one per
184
+ * `(ownerDID, recipientDID, grantorDID)` triple. Persisted so a restart never
185
+ * reissues a sequence number already handed out.
186
+ */
187
+ export type CredentialGrantorCounterTable = {
188
+ owner_did: string;
189
+ recipient_did: string;
190
+ grantor_did: string;
191
+ seq: number;
192
+ };
193
+ export type CredentialGrantorCounter = Selectable<CredentialGrantorCounterTable>;
194
+ export type InsertCredentialGrantorCounter = Insertable<CredentialGrantorCounterTable>;
195
+ /**
196
+ * Monotonic epoch counter minting `nextProvisioningEpoch`, one per
197
+ * `(ownerDID, recipientDID, grantorDID)` triple, respecting a `minEpoch` floor
198
+ * for post-rollback recovery. Persisted so a restart never reissues an epoch.
199
+ */
200
+ export type CredentialProvisioningEpochTable = {
201
+ owner_did: string;
202
+ recipient_did: string;
203
+ grantor_did: string;
204
+ epoch: number;
205
+ };
206
+ export type CredentialProvisioningEpoch = Selectable<CredentialProvisioningEpochTable>;
207
+ export type InsertCredentialProvisioningEpoch = Insertable<CredentialProvisioningEpochTable>;
208
+ /**
209
+ * The expectation floor for provisioning a credential manifest: a monotonic
210
+ * epoch counter gating when a provisioning attempt counts as complete.
211
+ * Readiness fails closed while an outstanding attempt row exists.
212
+ */
213
+ export type CredentialProvisioningExpectationTable = {
214
+ owner_did: string;
215
+ grantor_did: string;
216
+ epoch_floor: number;
217
+ };
218
+ export type CredentialProvisioningExpectation = Selectable<CredentialProvisioningExpectationTable>;
219
+ export type InsertCredentialProvisioningExpectation = Insertable<CredentialProvisioningExpectationTable>;
220
+ /**
221
+ * An outstanding provisioning attempt. Success deletes the row; retry failure
222
+ * keeps it; abandon deletes it without touching the expectation floor.
223
+ */
224
+ export type CredentialProvisioningAttemptTable = {
225
+ owner_did: string;
226
+ grantor_did: string;
227
+ attempt_id: string;
228
+ };
229
+ export type CredentialProvisioningAttempt = Selectable<CredentialProvisioningAttemptTable>;
230
+ export type InsertCredentialProvisioningAttempt = Insertable<CredentialProvisioningAttemptTable>;
231
+ /** One key a grantor recorded as granted, as it rides a manifest's `keys_json`. */
232
+ export type CredentialManifestKey = {
233
+ keyID: string;
234
+ version: number;
235
+ };
236
+ /**
237
+ * A grantor's advisory record of the key set it granted one recipient under one
238
+ * owner — the projection a recipient checks its own store against to prove
239
+ * completeness (the readiness union). Carries no key material, only ids/versions
240
+ * and the signed token that authenticated them.
241
+ *
242
+ * `(epoch, sequence)` gates every `put` lexicographically: a stale/replayed
243
+ * value is a silent no-op. `issuedAt` is a drift bound applied on admission,
244
+ * not persisted here.
245
+ *
246
+ * Triple columns are stored NORMALIZED (`normalizeDID`), so a `did:peer:4`
247
+ * short/long form converges on one row — matching the grantor's write to the
248
+ * recipient's readiness read.
249
+ */
250
+ export type CredentialManifestTable = {
251
+ owner_did: string;
252
+ recipient_did: string;
253
+ grantor_did: string;
254
+ epoch: number;
255
+ sequence: number;
256
+ keys_json: JSONArrayColumn<CredentialManifestKey>;
257
+ manifest_token: string;
258
+ delegation_tokens: JSONArrayColumn<string>;
259
+ };
260
+ export type CredentialManifest = Selectable<CredentialManifestTable>;
261
+ export type InsertCredentialManifest = Insertable<CredentialManifestTable>;
169
262
  export type CredentialTables = {
170
263
  kubun_credential_keys: CredentialKeyTable;
171
264
  kubun_credential_key_branches: CredentialKeyBranchTable;
@@ -173,5 +266,11 @@ export type CredentialTables = {
173
266
  kubun_credential_entries: CredentialEntryTable;
174
267
  kubun_credential_ops: CredentialOpTable;
175
268
  kubun_credential_aliases: KubunCredentialAliasTable;
269
+ kubun_credential_wrapping_authority: CredentialWrappingAuthorityTable;
270
+ kubun_credential_grantor_counters: CredentialGrantorCounterTable;
271
+ kubun_credential_provisioning_epochs: CredentialProvisioningEpochTable;
272
+ kubun_credential_provisioning_expectations: CredentialProvisioningExpectationTable;
273
+ kubun_credential_provisioning_attempts: CredentialProvisioningAttemptTable;
274
+ kubun_credential_manifests: CredentialManifestTable;
176
275
  };
177
276
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-credential",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -16,13 +16,13 @@
16
16
  ],
17
17
  "dependencies": {
18
18
  "@kokuin/token": "^0.5.0",
19
- "@kubun/db": "^0.14.0",
20
- "@kubun/db-adapter": "^0.14.0",
19
+ "@kubun/db": "^0.15.0",
20
+ "@kubun/db-adapter": "^0.15.0",
21
21
  "kysely": "^0.29.5"
22
22
  },
23
23
  "devDependencies": {
24
- "@kubun/db-better-sqlite": "^0.14.0",
25
- "@kubun/db-postgres": "^0.14.0",
24
+ "@kubun/db-better-sqlite": "^0.15.0",
25
+ "@kubun/db-postgres": "^0.15.0",
26
26
  "@testcontainers/postgresql": "^12.1.0"
27
27
  },
28
28
  "publishConfig": {