@kubun/store-credential 0.17.2 → 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 +29 -1
- package/lib/api.js +249 -17
- package/lib/migrations.js +2 -0
- package/package.json +1 -1
package/lib/api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Adapter } from '@kubun/db-adapter';
|
|
2
|
-
import type
|
|
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
|
package/lib/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { sql } from 'kysely';
|
|
2
3
|
/**
|
|
3
4
|
* `refreshSeq` sentinel for a delegation chain that has no future refresh —
|
|
4
5
|
* larger than any real ms timestamp, so a bounded refresh can never overtake
|
|
@@ -37,6 +38,26 @@ import { normalizeDID } from '@kokuin/token';
|
|
|
37
38
|
}).where('kubun_credential_wrapping_authority.refresh_seq', '<', values.refresh_seq)).execute();
|
|
38
39
|
}
|
|
39
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
|
+
}
|
|
40
61
|
async function writeOp(params) {
|
|
41
62
|
const { db, subjectKind, subjectID, op, deleted, allowKeyMove = false } = params;
|
|
42
63
|
const values = {
|
|
@@ -92,7 +113,7 @@ export function createCredentialStore(db, adapter) {
|
|
|
92
113
|
if (accept == null) {
|
|
93
114
|
await write(db);
|
|
94
115
|
} else {
|
|
95
|
-
await
|
|
116
|
+
await writeAmbient(db, async (tx)=>{
|
|
96
117
|
if (await accept(tx)) await write(tx);
|
|
97
118
|
});
|
|
98
119
|
}
|
|
@@ -100,7 +121,7 @@ export function createCredentialStore(db, adapter) {
|
|
|
100
121
|
}
|
|
101
122
|
const ownsTransaction = !db.isTransaction;
|
|
102
123
|
try {
|
|
103
|
-
await
|
|
124
|
+
await writeAmbient(db, async (tx)=>{
|
|
104
125
|
if (!await writeOp({
|
|
105
126
|
db: tx,
|
|
106
127
|
subjectKind,
|
|
@@ -125,7 +146,7 @@ export function createCredentialStore(db, adapter) {
|
|
|
125
146
|
await remove(db);
|
|
126
147
|
return;
|
|
127
148
|
}
|
|
128
|
-
await
|
|
149
|
+
await writeAmbient(db, async (tx)=>{
|
|
129
150
|
if (!await writeOp({
|
|
130
151
|
db: tx,
|
|
131
152
|
subjectKind,
|
|
@@ -173,6 +194,32 @@ export function createCredentialStore(db, adapter) {
|
|
|
173
194
|
allowKeyMove: allowMove
|
|
174
195
|
});
|
|
175
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);
|
|
176
223
|
const api = {
|
|
177
224
|
async transaction (fn) {
|
|
178
225
|
if (db.isTransaction) {
|
|
@@ -351,7 +398,7 @@ export function createCredentialStore(db, adapter) {
|
|
|
351
398
|
await remove(db);
|
|
352
399
|
return;
|
|
353
400
|
}
|
|
354
|
-
await
|
|
401
|
+
await writeAmbient(db, async (tx)=>{
|
|
355
402
|
for (const tombstone of tombstones){
|
|
356
403
|
if (!await writeOp({
|
|
357
404
|
db: tx,
|
|
@@ -372,6 +419,76 @@ export function createCredentialStore(db, adapter) {
|
|
|
372
419
|
async moveEntry (params, op) {
|
|
373
420
|
await writeEntry(params, op, true);
|
|
374
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
|
+
},
|
|
375
492
|
async getEntry (entryID) {
|
|
376
493
|
return await db.selectFrom('kubun_credential_entries').selectAll().where('entry_id', '=', entryID).executeTakeFirst();
|
|
377
494
|
},
|
|
@@ -450,19 +567,134 @@ export function createCredentialStore(db, adapter) {
|
|
|
450
567
|
return row?.entry_id ?? null;
|
|
451
568
|
},
|
|
452
569
|
async setAliasEntryID (namespace, key, entryID) {
|
|
453
|
-
await db.insertInto('kubun_credential_aliases').values({
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
entry_id: entryID
|
|
457
|
-
}).onConflict((oc)=>oc.columns([
|
|
458
|
-
'namespace',
|
|
459
|
-
'alias_key'
|
|
460
|
-
]).doUpdateSet({
|
|
570
|
+
await gatedIfAmbient(()=>db.insertInto('kubun_credential_aliases').values({
|
|
571
|
+
namespace,
|
|
572
|
+
alias_key: key,
|
|
461
573
|
entry_id: entryID
|
|
462
|
-
})).
|
|
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
|
+
};
|
|
463
692
|
},
|
|
464
693
|
async deleteAliasEntryID (namespace, key) {
|
|
465
|
-
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());
|
|
466
698
|
},
|
|
467
699
|
async listAliasKeys (namespace) {
|
|
468
700
|
const rows = await db.selectFrom('kubun_credential_aliases').select('alias_key').distinct().where('namespace', '=', namespace).execute();
|
|
@@ -558,7 +790,7 @@ export function createCredentialStore(db, adapter) {
|
|
|
558
790
|
const owner = normalizeDID(params.ownerDID);
|
|
559
791
|
const recipient = normalizeDID(params.recipientDID);
|
|
560
792
|
const grantor = normalizeDID(params.grantorDID);
|
|
561
|
-
await
|
|
793
|
+
await writeAmbient(db, async (tx)=>{
|
|
562
794
|
await tx.updateTable('kubun_credential_manifests').set((eb)=>({
|
|
563
795
|
epoch: eb.ref('kubun_credential_manifests.epoch')
|
|
564
796
|
})).where('owner_did', '=', owner).where('recipient_did', '=', recipient).where('grantor_did', '=', grantor).execute();
|
|
@@ -617,7 +849,7 @@ export function createCredentialStore(db, adapter) {
|
|
|
617
849
|
async beginProvisioningAttempt (params) {
|
|
618
850
|
const ownerDid = normalizeDID(params.ownerDID);
|
|
619
851
|
const grantorDid = normalizeDID(params.grantorDID);
|
|
620
|
-
await
|
|
852
|
+
await writeAmbient(db, async (tx)=>{
|
|
621
853
|
// Ensure the expectation floor row exists, idempotent at floor 0.
|
|
622
854
|
await tx.insertInto('kubun_credential_provisioning_expectations').values({
|
|
623
855
|
owner_did: ownerDid,
|
|
@@ -640,7 +872,7 @@ export function createCredentialStore(db, adapter) {
|
|
|
640
872
|
status: 'retry',
|
|
641
873
|
minEpoch: params.heldEpoch
|
|
642
874
|
};
|
|
643
|
-
await
|
|
875
|
+
await writeAmbient(db, async (tx)=>{
|
|
644
876
|
if (params.epoch > params.heldEpoch) {
|
|
645
877
|
// Phantom-attempt guard: if the id names no live attempt, move
|
|
646
878
|
// nothing — raising the floor for it would wedge readiness forever.
|
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();
|