@kubun/store-credential 0.17.1 → 0.17.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/lib/api.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Adapter } from '@kubun/db-adapter';
2
- import type { Kysely } from 'kysely';
2
+ import { type Kysely } from 'kysely';
3
3
  import type { CredentialEntry, CredentialEntryWithProvenance, CredentialFactorDescriptor, CredentialKey, CredentialKeyBranch, CredentialKeyState, CredentialManifestKey, CredentialOp, CredentialOpSubjectKind, CredentialTables, CredentialWrapping, CredentialWrappingWithBranch } from './tables.js';
4
4
  /**
5
5
  * The persisted half of a credential manifest — a structural subset of the
@@ -183,6 +183,15 @@ export type CredentialStoreAPI = {
183
183
  * source and target keys before invoking this capability.
184
184
  */
185
185
  moveEntry(params: PutCredentialEntryParams, op: CredentialOpInput | null): Promise<void>;
186
+ /**
187
+ * Write over an entry's ciphertext ONLY if the row already exists under the
188
+ * same key — never inserts, never moves it. Op-first like putEntry: the op is
189
+ * written under the op-conflict lock, then the row; if no matching row is
190
+ * present (concurrently deleted, or re-keyed away) the op is rolled back, so a
191
+ * refresh writeback can neither revive a concurrently-deleted entry nor leave
192
+ * an op stranded without its row. Returns 'absent' when nothing was updated.
193
+ */
194
+ updateEntryIfPresent(params: PutCredentialEntryParams, op: CredentialOpInput | null): Promise<'updated' | 'absent'>;
186
195
  getEntry(entryID: string): Promise<CredentialEntry | undefined>;
187
196
  getEntryWithProvenance(entryID: string): Promise<CredentialEntryWithProvenance | undefined>;
188
197
  listEntries(keyID: string): Promise<Array<CredentialEntry>>;
@@ -228,8 +237,27 @@ export type CredentialStoreAPI = {
228
237
  getAliasEntryID(namespace: string, key: string): Promise<string | null>;
229
238
  /** Point `(namespace, key)` at `entryID`, replacing any prior target. */
230
239
  setAliasEntryID(namespace: string, key: string, entryID: string): Promise<void>;
240
+ /**
241
+ * Bind `(namespace, aliasKey)` to `entryID` only if the alias slot is free and
242
+ * the entry exists — unlike {@link setAliasEntryID}, this never overwrites an
243
+ * existing alias and never binds a nonexistent entry. Classifies the outcome
244
+ * instead of throwing, since both unique constraints are expected outcomes a
245
+ * caller branches on, not exceptional ones.
246
+ */
247
+ bindAliasIfAbsent(namespace: string, aliasKey: string, entryID: string): Promise<'inserted' | 'entry-missing' | {
248
+ conflict: 'alias';
249
+ existing: string;
250
+ } | {
251
+ conflict: 'entry';
252
+ }>;
231
253
  /** Clear an alias. A no-op, not an error, when none is set. */
232
254
  deleteAliasEntryID(namespace: string, key: string): Promise<void>;
255
+ /**
256
+ * Clear an alias only while it still points at `entryID`. A concurrent rebind
257
+ * that repaired the pointer onto a fresh entry in the gap after the old entry
258
+ * was deleted is left intact; a no-op when the pointer already moved or is unset.
259
+ */
260
+ deleteAliasIfEntry(namespace: string, key: string, entryID: string): Promise<void>;
233
261
  /**
234
262
  * The distinct keys currently set within `namespace`. Keys stay opaque here —
235
263
  * a caller interprets them (e.g. connector credentials store the normalized
@@ -361,5 +389,48 @@ export type CredentialStoreAPI = {
361
389
  * `listProvisioningExpectations` can't give on its own (it's per-owner).
362
390
  */
363
391
  listProvisioningExpectationOwners(): Promise<Array<string>>;
392
+ /**
393
+ * Refresh a controller-owned key's `credential/administer` delegation chain,
394
+ * gated strictly-greater on `refreshSeq` (stale/replayed is a silent no-op,
395
+ * same shape as {@link CredentialStoreAPI.putWrappingAuthority}). Handles
396
+ * both the absent-row insert and the monotone race. Returns whether a row
397
+ * was inserted or updated.
398
+ */
399
+ refreshKeyOwnerDelegation(params: {
400
+ keyID: string;
401
+ delegationTokens: Array<string>;
402
+ refreshSeq: number;
403
+ }): Promise<boolean>;
404
+ /**
405
+ * Compare-and-swap a key-owner delegation chain, guarded on the `revision`
406
+ * the caller read. Replaces the row only when its CURRENT `revision` still
407
+ * equals `expectedRevision`, so a writer that read an invalid-present chain
408
+ * and revalidated it can install a replacement without racing another such
409
+ * writer or a concurrent refresh. The guard is `revision`, NOT `refreshSeq`:
410
+ * two distinct valid chains can share a `refreshSeq` (min-exp ms), so guarding
411
+ * on the seq has an ABA hole. Unlike {@link refreshKeyOwnerDelegation} this
412
+ * never inserts and is not monotone — it is the escape hatch for the
413
+ * invalid-present case, where the incoming chain's `refreshSeq` may be lower.
414
+ * Absent row is a no-op false, same as a revision mismatch. A successful
415
+ * write bumps `revision`.
416
+ */
417
+ replaceKeyOwnerDelegationIfUnchanged(params: {
418
+ keyID: string;
419
+ expectedRevision: number;
420
+ delegationTokens: Array<string>;
421
+ refreshSeq: number;
422
+ }): Promise<boolean>;
423
+ /** The stored delegation chain, its `refreshSeq`, and its `revision`, or `undefined` when unset. */
424
+ getKeyOwnerDelegation(keyID: string): Promise<{
425
+ delegationTokens: Array<string>;
426
+ refreshSeq: number;
427
+ revision: number;
428
+ } | undefined>;
364
429
  };
430
+ /**
431
+ * `refreshSeq` sentinel for a delegation chain that has no future refresh —
432
+ * larger than any real ms timestamp, so a bounded refresh can never overtake
433
+ * it. Consumed by `@kubun/plugin-p2p`.
434
+ */
435
+ export declare const UNBOUNDED_REFRESH_SEQ = 8640000000000000;
365
436
  export declare function createCredentialStore(db: Kysely<CredentialTables>, adapter: Adapter): CredentialStoreAPI;
package/lib/api.js CHANGED
@@ -1,4 +1,10 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
+ import { sql } from 'kysely';
3
+ /**
4
+ * `refreshSeq` sentinel for a delegation chain that has no future refresh —
5
+ * larger than any real ms timestamp, so a bounded refresh can never overtake
6
+ * it. Consumed by `@kubun/plugin-p2p`.
7
+ */ export const UNBOUNDED_REFRESH_SEQ = 8_640_000_000_000_000;
2
8
  /**
3
9
  * Run `write` as one atomic unit.
4
10
  *
@@ -32,6 +38,26 @@ import { normalizeDID } from '@kokuin/token';
32
38
  }).where('kubun_credential_wrapping_authority.refresh_seq', '<', values.refresh_seq)).execute();
33
39
  }
34
40
  const OP_REJECTED = Symbol('credential op rejected');
41
+ // A repair that repoints a dangling alias can collide with UNIQUE(entry_id) when the
42
+ // caller's entry is already aliased under another name. The error classes and codes
43
+ // share nothing across the two drivers, so match a uniqueness marker AND the exact
44
+ // identifier each driver reports — never a bare substring, which would also swallow an
45
+ // unrelated error that merely mentions the column. Postgres: SQLSTATE 23505 plus the
46
+ // constraint name (on the error or in the message). SQLite: "UNIQUE constraint failed"
47
+ // plus the table.column.
48
+ function isEntryAliasUniqueViolation(error) {
49
+ if (error == null || typeof error !== 'object') return false;
50
+ const { message, code, constraint_name, constraint } = error;
51
+ const msg = typeof message === 'string' ? message : '';
52
+ const name = typeof constraint === 'string' && constraint || typeof constraint_name === 'string' && constraint_name || '';
53
+ const entryConstraint = 'uq_kubun_credential_aliases_entry' // Postgres constraint
54
+ ;
55
+ const entryColumn = 'kubun_credential_aliases.entry_id' // SQLite column
56
+ ;
57
+ const postgres = (code === '23505' || msg.includes('duplicate key value')) && (name === entryConstraint || msg.includes(entryConstraint));
58
+ const sqlite = msg.includes('UNIQUE constraint failed') && msg.includes(entryColumn);
59
+ return postgres || sqlite;
60
+ }
35
61
  async function writeOp(params) {
36
62
  const { db, subjectKind, subjectID, op, deleted, allowKeyMove = false } = params;
37
63
  const values = {
@@ -87,7 +113,7 @@ export function createCredentialStore(db, adapter) {
87
113
  if (accept == null) {
88
114
  await write(db);
89
115
  } else {
90
- await writeAtomically(db, async (tx)=>{
116
+ await writeAmbient(db, async (tx)=>{
91
117
  if (await accept(tx)) await write(tx);
92
118
  });
93
119
  }
@@ -95,7 +121,7 @@ export function createCredentialStore(db, adapter) {
95
121
  }
96
122
  const ownsTransaction = !db.isTransaction;
97
123
  try {
98
- await writeAtomically(db, async (tx)=>{
124
+ await writeAmbient(db, async (tx)=>{
99
125
  if (!await writeOp({
100
126
  db: tx,
101
127
  subjectKind,
@@ -120,7 +146,7 @@ export function createCredentialStore(db, adapter) {
120
146
  await remove(db);
121
147
  return;
122
148
  }
123
- await writeAtomically(db, async (tx)=>{
149
+ await writeAmbient(db, async (tx)=>{
124
150
  if (!await writeOp({
125
151
  db: tx,
126
152
  subjectKind,
@@ -168,6 +194,32 @@ export function createCredentialStore(db, adapter) {
168
194
  allowKeyMove: allowMove
169
195
  });
170
196
  }
197
+ // One async gate serializes EVERY write that joins a caller-supplied ambient
198
+ // transaction. The SAVEPOINT sections below (updateEntryIfPresent's op+row fence,
199
+ // bindAliasIfAbsent's repair fence) then hold the connection exclusively for their
200
+ // whole open → rollback/release window, so a ROLLBACK TO SAVEPOINT can never undo
201
+ // another concurrent write's committed work — a savepoint rollback reverts ALL work
202
+ // since the savepoint, not just this operation's statements. getStates resolves its
203
+ // connectors with Promise.all over one shared request tx, the source of that
204
+ // concurrency. The gate covers not only the savepoint sections but every ambient
205
+ // write (writeAmbient, and the raw alias writers via gatedIfAmbient), so no ungated
206
+ // statement can land inside a savepoint window. Reads are never gated. Owned
207
+ // transactions (db.isTransaction === false) open their own connection and are
208
+ // serialized by the driver, so they skip the gate. The gate is per api instance:
209
+ // `db.isTransaction` is fixed per instance, so a fresh transaction builds a fresh
210
+ // store with its own gate.
211
+ let ambientWriteChain = Promise.resolve();
212
+ const withAmbientWrite = (fn)=>{
213
+ const run = ambientWriteChain.then(fn, fn);
214
+ ambientWriteChain = run.then(()=>undefined, ()=>undefined);
215
+ return run;
216
+ };
217
+ // Gate a single raw ambient statement; run it directly when this store owns no
218
+ // ambient transaction (nothing else can interleave a savepoint there).
219
+ const gatedIfAmbient = (fn)=>db.isTransaction ? withAmbientWrite(fn) : fn();
220
+ // writeAtomically, but the inline (ambient) branch runs under the gate so it cannot
221
+ // interleave a concurrent savepoint section. The owned branch opens its own tx.
222
+ const writeAmbient = (innerDb, write)=>innerDb.isTransaction ? withAmbientWrite(()=>writeAtomically(innerDb, write)) : writeAtomically(innerDb, write);
171
223
  const api = {
172
224
  async transaction (fn) {
173
225
  if (db.isTransaction) {
@@ -346,7 +398,7 @@ export function createCredentialStore(db, adapter) {
346
398
  await remove(db);
347
399
  return;
348
400
  }
349
- await writeAtomically(db, async (tx)=>{
401
+ await writeAmbient(db, async (tx)=>{
350
402
  for (const tombstone of tombstones){
351
403
  if (!await writeOp({
352
404
  db: tx,
@@ -367,6 +419,76 @@ export function createCredentialStore(db, adapter) {
367
419
  async moveEntry (params, op) {
368
420
  await writeEntry(params, op, true);
369
421
  },
422
+ async updateEntryIfPresent (params, op) {
423
+ let outcome = 'absent';
424
+ const runWrite = async (tx)=>{
425
+ // Op-first, like putEntry/deleteEntry: take the op lock before the entry row,
426
+ // the same order deleteEntry uses, so a row-first inversion cannot deadlock a
427
+ // concurrent delete. writeOp's LWW guard rejects a stale-HLC writeback and its
428
+ // key_id predicate (default allowKeyMove = false) one aimed at a key the entry
429
+ // was re-keyed away from; either way it returns false and nothing is touched.
430
+ // Its insert branch mints the signed op for a GraphQL-created op-less entry
431
+ // (putEntry(..., null)), so that entry's first refresh is preserved.
432
+ if (op != null && !await writeOp({
433
+ db: tx,
434
+ subjectKind: 'entry',
435
+ subjectID: params.entryID,
436
+ op,
437
+ deleted: 0
438
+ })) {
439
+ return;
440
+ }
441
+ // The op winning does NOT prove the entry row is present under this key: the
442
+ // GraphQL admin surface writes a row with no op (putEntry(..., null)) and
443
+ // removes a row without touching its op (deleteEntry(..., null)), so the row
444
+ // can be absent or re-keyed here. UPDATE-only, keyed on entry_id AND key_id;
445
+ // on a miss, throw to roll the provisional op back, so op and row never
446
+ // diverge and no live op is left without its row.
447
+ const result = await tx.updateTable('kubun_credential_entries').set({
448
+ key_version: params.keyVersion,
449
+ iv: adapter.encodeBinary(params.iv),
450
+ ciphertext: adapter.encodeBinary(params.ciphertext),
451
+ hlc: params.hlc
452
+ }).where('entry_id', '=', params.entryID).where('key_id', '=', params.keyID).executeTakeFirst();
453
+ if ((result.numUpdatedRows ?? 0n) === 0n) {
454
+ if (op != null) throw OP_REJECTED;
455
+ return;
456
+ }
457
+ outcome = 'updated';
458
+ };
459
+ if (!db.isTransaction) {
460
+ // We own the transaction: writeAtomically opens it and an OP_REJECTED throw
461
+ // rolls the provisional op back before it can commit.
462
+ try {
463
+ await writeAmbient(db, runWrite);
464
+ } catch (error) {
465
+ if (error === OP_REJECTED) return 'absent';
466
+ throw error;
467
+ }
468
+ return outcome;
469
+ }
470
+ // Ambient caller transaction (e.g. a connector mutation's per-request refresh
471
+ // stack joins its tx). writeAtomically runs inline here with no rollback fence,
472
+ // and the refresh decorator catches every writeback error — so a bare throw
473
+ // would leave the provisional op committed with no row. Fence the op+row writes
474
+ // in a savepoint: a miss rolls back ONLY the provisional op, leaving the
475
+ // caller's transaction intact. The section runs under withAmbientWrite so no other
476
+ // ambient write can land inside this savepoint window and be reverted with it.
477
+ // SAVEPOINT is portable across SQLite and Postgres.
478
+ return await withAmbientWrite(async ()=>{
479
+ await sql`SAVEPOINT kubun_cred_update_entry`.execute(db);
480
+ try {
481
+ await runWrite(db);
482
+ } catch (error) {
483
+ await sql`ROLLBACK TO SAVEPOINT kubun_cred_update_entry`.execute(db);
484
+ await sql`RELEASE SAVEPOINT kubun_cred_update_entry`.execute(db);
485
+ if (error === OP_REJECTED) return 'absent';
486
+ throw error;
487
+ }
488
+ await sql`RELEASE SAVEPOINT kubun_cred_update_entry`.execute(db);
489
+ return outcome;
490
+ });
491
+ },
370
492
  async getEntry (entryID) {
371
493
  return await db.selectFrom('kubun_credential_entries').selectAll().where('entry_id', '=', entryID).executeTakeFirst();
372
494
  },
@@ -445,19 +567,134 @@ export function createCredentialStore(db, adapter) {
445
567
  return row?.entry_id ?? null;
446
568
  },
447
569
  async setAliasEntryID (namespace, key, entryID) {
448
- await db.insertInto('kubun_credential_aliases').values({
449
- namespace,
450
- alias_key: key,
451
- entry_id: entryID
452
- }).onConflict((oc)=>oc.columns([
453
- 'namespace',
454
- 'alias_key'
455
- ]).doUpdateSet({
570
+ await gatedIfAmbient(()=>db.insertInto('kubun_credential_aliases').values({
571
+ namespace,
572
+ alias_key: key,
456
573
  entry_id: entryID
457
- })).execute();
574
+ }).onConflict((oc)=>oc.columns([
575
+ 'namespace',
576
+ 'alias_key'
577
+ ]).doUpdateSet({
578
+ entry_id: entryID
579
+ })).execute());
580
+ },
581
+ async bindAliasIfAbsent (namespace, aliasKey, entryID) {
582
+ // Entry-existence-conditioned insert: the alias row is written only in the same
583
+ // statement that confirms the entry exists. ON CONFLICT DO NOTHING fires for either
584
+ // the (namespace, alias_key) PK or the UNIQUE(entry_id); the read-back names which.
585
+ //
586
+ // The table has no FK to entries (SQLite ships with foreign_keys OFF, so a declared
587
+ // FK would be unenforced there). The INSERT...SELECT closes the race on any
588
+ // single-writer store; deleteCredential removes the entry before the pointer so a
589
+ // bind racing a delete refuses cleanly rather than dangling. A pointer that still
590
+ // outlives its entry (a Postgres delete committing mid-statement) is recovered two
591
+ // ways: getCredential/getCredentialRef resolve a missing entry to null, and the
592
+ // repair path below repoints a dangling pointer to a fresh, present entry.
593
+ const tryInsert = async ()=>{
594
+ const inserted = await gatedIfAmbient(()=>db.insertInto('kubun_credential_aliases').columns([
595
+ 'namespace',
596
+ 'alias_key',
597
+ 'entry_id'
598
+ ]).expression((eb)=>eb.selectFrom('kubun_credential_entries').where('entry_id', '=', entryID).select((eb2)=>[
599
+ eb2.val(namespace).as('namespace'),
600
+ eb2.val(aliasKey).as('alias_key'),
601
+ eb2.ref('entry_id').as('entry_id')
602
+ ])).onConflict((oc)=>oc.doNothing()).returning('entry_id').executeTakeFirst());
603
+ return inserted != null;
604
+ };
605
+ const aliasAt = async ()=>{
606
+ const row = await db.selectFrom('kubun_credential_aliases').select('entry_id').where('namespace', '=', namespace).where('alias_key', '=', aliasKey).executeTakeFirst();
607
+ return row?.entry_id ?? null;
608
+ };
609
+ const entryExists = async (id)=>{
610
+ const row = await db.selectFrom('kubun_credential_entries').select('entry_id').where('entry_id', '=', id).executeTakeFirst();
611
+ return row != null;
612
+ };
613
+ // Repoint this alias to entryID, but ONLY while its stored pointer is still
614
+ // dangling (its entry row absent), so a concurrently-installed valid binding is
615
+ // never overwritten — the no-repoint guarantee. A UNIQUE(entry_id) clash (entryID
616
+ // already aliased elsewhere) must not abort an ambient Postgres transaction, so a
617
+ // caller-tx write is fenced in a savepoint and the clash mapped to conflict:'entry'.
618
+ const repairDangling = async (observedExisting)=>{
619
+ const doUpdate = async ()=>{
620
+ const res = await db.updateTable('kubun_credential_aliases').set({
621
+ entry_id: entryID
622
+ }).where('namespace', '=', namespace).where('alias_key', '=', aliasKey)// Compare-and-swap on the pointer: repoint only while it still holds the
623
+ // dangling id we observed. If a concurrent transaction repointed it onto a
624
+ // freshly created entry, this matches zero rows — under Postgres READ
625
+ // COMMITTED the re-checked current row carries the new id, so a valid binding
626
+ // is never overwritten even when that new entry is outside our snapshot.
627
+ .where('entry_id', '=', observedExisting)// The observed pointer must still be dangling (its entry absent)...
628
+ .where((eb)=>eb.not(eb.exists(eb.selectFrom('kubun_credential_entries as e').select(sql`1`.as('one')).whereRef('e.entry_id', '=', 'kubun_credential_aliases.entry_id'))))// ...and the target must be present, so repair can never form a NEW dangling
629
+ // pointer even if the target is deleted between the pre-check and here.
630
+ .where((eb)=>eb.exists(eb.selectFrom('kubun_credential_entries as t').select(sql`1`.as('one')).where('t.entry_id', '=', entryID))).executeTakeFirst();
631
+ return (res.numUpdatedRows ?? 0n) > 0n;
632
+ };
633
+ if (!db.isTransaction) {
634
+ try {
635
+ return await doUpdate();
636
+ } catch (error) {
637
+ if (isEntryAliasUniqueViolation(error)) return 'entry-clash';
638
+ throw error;
639
+ }
640
+ }
641
+ return await withAmbientWrite(async ()=>{
642
+ await sql`SAVEPOINT kubun_cred_bind_repair`.execute(db);
643
+ try {
644
+ const repaired = await doUpdate();
645
+ await sql`RELEASE SAVEPOINT kubun_cred_bind_repair`.execute(db);
646
+ return repaired;
647
+ } catch (error) {
648
+ await sql`ROLLBACK TO SAVEPOINT kubun_cred_bind_repair`.execute(db);
649
+ await sql`RELEASE SAVEPOINT kubun_cred_bind_repair`.execute(db);
650
+ if (isEntryAliasUniqueViolation(error)) return 'entry-clash';
651
+ throw error;
652
+ }
653
+ });
654
+ };
655
+ if (await tryInsert()) return 'inserted';
656
+ if (!await entryExists(entryID)) return 'entry-missing';
657
+ const existing = await aliasAt();
658
+ // PK free but the insert did not fire and the entry exists → the caller's entry is
659
+ // already aliased under another name (UNIQUE(entry_id)).
660
+ if (existing == null) return {
661
+ conflict: 'entry'
662
+ };
663
+ if (existing === entryID) return {
664
+ conflict: 'alias',
665
+ existing
666
+ };
667
+ // PK held by a different entry; repair only if that pointer is dangling.
668
+ if (await entryExists(existing)) return {
669
+ conflict: 'alias',
670
+ existing
671
+ };
672
+ const repaired = await repairDangling(existing);
673
+ if (repaired === true) return 'inserted';
674
+ if (repaired === 'entry-clash') return {
675
+ conflict: 'entry'
676
+ };
677
+ // The pointer stopped being dangling under us, or the target vanished (a
678
+ // concurrent rebind, repair, or delete). Reclassify against the current state,
679
+ // re-checking the target so a target that disappeared reports entry-missing
680
+ // rather than a spurious entry conflict.
681
+ if (await tryInsert()) return 'inserted';
682
+ if (!await entryExists(entryID)) return 'entry-missing';
683
+ const now = await aliasAt();
684
+ if (now == null) return {
685
+ conflict: 'entry'
686
+ };
687
+ if (now === entryID) return 'inserted';
688
+ return {
689
+ conflict: 'alias',
690
+ existing: now
691
+ };
458
692
  },
459
693
  async deleteAliasEntryID (namespace, key) {
460
- await db.deleteFrom('kubun_credential_aliases').where('namespace', '=', namespace).where('alias_key', '=', key).execute();
694
+ await gatedIfAmbient(()=>db.deleteFrom('kubun_credential_aliases').where('namespace', '=', namespace).where('alias_key', '=', key).execute());
695
+ },
696
+ async deleteAliasIfEntry (namespace, key, entryID) {
697
+ await gatedIfAmbient(()=>db.deleteFrom('kubun_credential_aliases').where('namespace', '=', namespace).where('alias_key', '=', key).where('entry_id', '=', entryID).execute());
461
698
  },
462
699
  async listAliasKeys (namespace) {
463
700
  const rows = await db.selectFrom('kubun_credential_aliases').select('alias_key').distinct().where('namespace', '=', namespace).execute();
@@ -553,7 +790,7 @@ export function createCredentialStore(db, adapter) {
553
790
  const owner = normalizeDID(params.ownerDID);
554
791
  const recipient = normalizeDID(params.recipientDID);
555
792
  const grantor = normalizeDID(params.grantorDID);
556
- await writeAtomically(db, async (tx)=>{
793
+ await writeAmbient(db, async (tx)=>{
557
794
  await tx.updateTable('kubun_credential_manifests').set((eb)=>({
558
795
  epoch: eb.ref('kubun_credential_manifests.epoch')
559
796
  })).where('owner_did', '=', owner).where('recipient_did', '=', recipient).where('grantor_did', '=', grantor).execute();
@@ -612,7 +849,7 @@ export function createCredentialStore(db, adapter) {
612
849
  async beginProvisioningAttempt (params) {
613
850
  const ownerDid = normalizeDID(params.ownerDID);
614
851
  const grantorDid = normalizeDID(params.grantorDID);
615
- await writeAtomically(db, async (tx)=>{
852
+ await writeAmbient(db, async (tx)=>{
616
853
  // Ensure the expectation floor row exists, idempotent at floor 0.
617
854
  await tx.insertInto('kubun_credential_provisioning_expectations').values({
618
855
  owner_did: ownerDid,
@@ -635,7 +872,7 @@ export function createCredentialStore(db, adapter) {
635
872
  status: 'retry',
636
873
  minEpoch: params.heldEpoch
637
874
  };
638
- await writeAtomically(db, async (tx)=>{
875
+ await writeAmbient(db, async (tx)=>{
639
876
  if (params.epoch > params.heldEpoch) {
640
877
  // Phantom-attempt guard: if the id names no live attempt, move
641
878
  // nothing — raising the floor for it would wedge readiness forever.
@@ -696,6 +933,42 @@ export function createCredentialStore(db, adapter) {
696
933
  return [
697
934
  ...owners
698
935
  ];
936
+ },
937
+ async refreshKeyOwnerDelegation (params) {
938
+ const values = {
939
+ key_id: params.keyID,
940
+ delegation_tokens: adapter.encodeJSON(params.delegationTokens),
941
+ refresh_seq: params.refreshSeq,
942
+ revision: 1
943
+ };
944
+ const result = await db.insertInto('kubun_credential_key_owner_delegation').values(values).onConflict((oc)=>oc.column('key_id').doUpdateSet({
945
+ delegation_tokens: values.delegation_tokens,
946
+ refresh_seq: values.refresh_seq,
947
+ // Bump so a concurrent replace CAS on the pre-refresh revision fails.
948
+ revision: (eb)=>eb('kubun_credential_key_owner_delegation.revision', '+', 1)
949
+ }).where('kubun_credential_key_owner_delegation.refresh_seq', '<', values.refresh_seq)).executeTakeFirst();
950
+ return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
951
+ },
952
+ async replaceKeyOwnerDelegationIfUnchanged (params) {
953
+ const result = await db.updateTable('kubun_credential_key_owner_delegation').set({
954
+ delegation_tokens: adapter.encodeJSON(params.delegationTokens),
955
+ refresh_seq: params.refreshSeq,
956
+ revision: (eb)=>eb('kubun_credential_key_owner_delegation.revision', '+', 1)
957
+ }).where('key_id', '=', params.keyID).where('revision', '=', params.expectedRevision).executeTakeFirst();
958
+ return (result.numUpdatedRows ?? 0n) > 0n;
959
+ },
960
+ async getKeyOwnerDelegation (keyID) {
961
+ const row = await db.selectFrom('kubun_credential_key_owner_delegation').select([
962
+ 'delegation_tokens',
963
+ 'refresh_seq',
964
+ 'revision'
965
+ ]).where('key_id', '=', keyID).executeTakeFirst();
966
+ if (row == null) return undefined;
967
+ return {
968
+ delegationTokens: row.delegation_tokens,
969
+ refreshSeq: row.refresh_seq,
970
+ revision: row.revision
971
+ };
699
972
  }
700
973
  };
701
974
  return api;
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
3
  export type { CredentialManifestRecord, CredentialOpInput, CredentialStoreAPI, CredentialTombstoneInput, HeldCredentialSubject, ListManifestsParams, NextGrantorSequenceParams, PutCredentialEntryParams, PutCredentialKeyParams, PutCredentialWrappingParams, PutWrappingAuthorityParams, StoredCredentialManifest, WrappingAuthority, } from './api.js';
4
- export { createCredentialStore } from './api.js';
4
+ export { createCredentialStore, UNBOUNDED_REFRESH_SEQ } 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, 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';
8
+ export type { CredentialAlias, CredentialEntry, CredentialEntryTable, CredentialEntryWithProvenance, CredentialFactorDescriptor, CredentialGrantorCounter, CredentialGrantorCounterTable, CredentialKey, CredentialKeyBranch, CredentialKeyBranchTable, CredentialKeyOwnerDelegation, CredentialKeyOwnerDelegationTable, CredentialKeyState, CredentialKeyTable, CredentialManifest, CredentialManifestKey, CredentialManifestTable, CredentialOp, CredentialOpSubjectKind, CredentialOpTable, CredentialTables, CredentialWrapping, CredentialWrappingAuthority, CredentialWrappingAuthorityTable, CredentialWrappingTable, CredentialWrappingWithBranch, InsertCredentialAlias, InsertCredentialEntry, InsertCredentialGrantorCounter, InsertCredentialKey, InsertCredentialKeyOwnerDelegation, InsertCredentialManifest, InsertCredentialOp, InsertCredentialWrapping, InsertCredentialWrappingAuthority, KubunCredentialAliasTable, } from './tables.js';
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { createCredentialStore } from './api.js';
1
+ export { createCredentialStore, UNBOUNDED_REFRESH_SEQ } from './api.js';
2
2
  export { credentialStoreDefinition } from './definition.js';
3
3
  export const CREDENTIAL_STORE = 'credential';
4
4
  export function getCredentialStore(provider) {
package/lib/migrations.js CHANGED
@@ -48,6 +48,8 @@ export function getCredentialMigrations(ctx) {
48
48
  await db.schema.createTable('kubun_credential_aliases').ifNotExists().addColumn('namespace', t.text, (col)=>col.notNull()).addColumn('alias_key', t.text, (col)=>col.notNull()).addColumn('entry_id', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addPrimaryKeyConstraint('pk_kubun_credential_aliases', [
49
49
  'namespace',
50
50
  'alias_key'
51
+ ]).addUniqueConstraint('uq_kubun_credential_aliases_entry', [
52
+ 'entry_id'
51
53
  ]).execute();
52
54
  // Grantor-authority envelope for a wrapping; `refresh_seq` gates every upsert strictly-greater.
53
55
  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();
@@ -80,8 +82,14 @@ export function getCredentialMigrations(ctx) {
80
82
  'recipient_did',
81
83
  'grantor_did'
82
84
  ]).execute();
85
+ // A controller-owned key's administer delegation chain, keyed on the key
86
+ // itself; `refresh_seq` gates every upsert strictly-greater.
87
+ await db.schema.createTable('kubun_credential_key_owner_delegation').ifNotExists().addColumn('key_id', t.text, (col)=>col.notNull().primaryKey()).addColumn('delegation_tokens', t.json, (col)=>col.notNull()).addColumn('refresh_seq', t.bigint, (col)=>col.notNull())// Per-write revision for the replace CAS; `refresh_seq` is not a unique
88
+ // version (see CredentialKeyOwnerDelegationTable).
89
+ .addColumn('revision', t.bigint, (col)=>col.notNull()).execute();
83
90
  },
84
91
  async down (db) {
92
+ await db.schema.dropTable('kubun_credential_key_owner_delegation').ifExists().execute();
85
93
  await db.schema.dropTable('kubun_credential_manifests').ifExists().execute();
86
94
  await db.schema.dropTable('kubun_credential_provisioning_attempts').ifExists().execute();
87
95
  await db.schema.dropTable('kubun_credential_provisioning_expectations').ifExists().execute();
package/lib/tables.d.ts CHANGED
@@ -259,6 +259,21 @@ export type CredentialManifestTable = {
259
259
  };
260
260
  export type CredentialManifest = Selectable<CredentialManifestTable>;
261
261
  export type InsertCredentialManifest = Insertable<CredentialManifestTable>;
262
+ /**
263
+ * A controller-owned key's `credential/administer` delegation chain, keyed on
264
+ * the key itself rather than a triple — the chain authorizes whoever holds it
265
+ * to administer THIS key, independent of any one (owner, recipient, grantor)
266
+ * relationship. `refresh_seq` gates every `refresh` strictly-greater, same
267
+ * shape as {@link CredentialWrappingAuthorityTable}.
268
+ */
269
+ export type CredentialKeyOwnerDelegationTable = {
270
+ key_id: string;
271
+ delegation_tokens: JSONArrayColumn<string>;
272
+ refresh_seq: number;
273
+ revision: number;
274
+ };
275
+ export type CredentialKeyOwnerDelegation = Selectable<CredentialKeyOwnerDelegationTable>;
276
+ export type InsertCredentialKeyOwnerDelegation = Insertable<CredentialKeyOwnerDelegationTable>;
262
277
  export type CredentialTables = {
263
278
  kubun_credential_keys: CredentialKeyTable;
264
279
  kubun_credential_key_branches: CredentialKeyBranchTable;
@@ -272,5 +287,6 @@ export type CredentialTables = {
272
287
  kubun_credential_provisioning_expectations: CredentialProvisioningExpectationTable;
273
288
  kubun_credential_provisioning_attempts: CredentialProvisioningAttemptTable;
274
289
  kubun_credential_manifests: CredentialManifestTable;
290
+ kubun_credential_key_owner_delegation: CredentialKeyOwnerDelegationTable;
275
291
  };
276
292
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-credential",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -18,11 +18,11 @@
18
18
  "@kokuin/token": "^0.5.0",
19
19
  "@kubun/db": "^0.17.0",
20
20
  "@kubun/db-adapter": "^0.17.0",
21
- "kysely": "^0.29.5"
21
+ "kysely": "^0.29.6"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@kubun/db-better-sqlite": "^0.17.0",
25
- "@kubun/db-postgres": "^0.17.0",
25
+ "@kubun/db-postgres": "^0.17.1",
26
26
  "@testcontainers/postgresql": "^12.1.0"
27
27
  },
28
28
  "publishConfig": {