@kubun/store-credential 0.17.2 → 0.17.4
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 +48 -1
- package/lib/api.js +258 -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>>;
|
|
@@ -226,10 +235,48 @@ export type CredentialStoreAPI = {
|
|
|
226
235
|
* whatever it means by "key" before calling.
|
|
227
236
|
*/
|
|
228
237
|
getAliasEntryID(namespace: string, key: string): Promise<string | null>;
|
|
238
|
+
/**
|
|
239
|
+
* The provider namespace this entry is aliased under, or null when the entry
|
|
240
|
+
* has no alias row. UNIQUE(entry_id) makes this a 1:1 reverse lookup.
|
|
241
|
+
*/
|
|
242
|
+
aliasNamespaceForEntry(entryID: string): Promise<string | null>;
|
|
229
243
|
/** Point `(namespace, key)` at `entryID`, replacing any prior target. */
|
|
230
244
|
setAliasEntryID(namespace: string, key: string, entryID: string): Promise<void>;
|
|
245
|
+
/**
|
|
246
|
+
* Bind `(namespace, aliasKey)` to `entryID` only if the alias slot is free and
|
|
247
|
+
* the entry exists — unlike {@link setAliasEntryID}, this never overwrites an
|
|
248
|
+
* existing alias and never binds a nonexistent entry. Classifies the outcome
|
|
249
|
+
* instead of throwing, since both unique constraints are expected outcomes a
|
|
250
|
+
* caller branches on, not exceptional ones.
|
|
251
|
+
*
|
|
252
|
+
* When `expectedKeyID` is given, the bind is additionally conditioned, in the
|
|
253
|
+
* same statement, on the entry belonging to that key. This closes a
|
|
254
|
+
* time-of-check/time-of-use gap for callers that cannot wrap the check and the
|
|
255
|
+
* bind in one transaction: an entry verified to belong to a key can be moved to
|
|
256
|
+
* another key (a supported manager operation) before an unconditioned insert
|
|
257
|
+
* fires, which would point this alias at an entry now owned by a different key.
|
|
258
|
+
* Folding the key check into the insert refuses atomically. When omitted, only
|
|
259
|
+
* entry existence is required (owner-gated callers verify authority elsewhere).
|
|
260
|
+
*/
|
|
261
|
+
bindAliasIfAbsent(params: {
|
|
262
|
+
namespace: string;
|
|
263
|
+
aliasKey: string;
|
|
264
|
+
entryID: string;
|
|
265
|
+
expectedKeyID?: string;
|
|
266
|
+
}): Promise<'inserted' | 'entry-missing' | {
|
|
267
|
+
conflict: 'alias';
|
|
268
|
+
existing: string;
|
|
269
|
+
} | {
|
|
270
|
+
conflict: 'entry';
|
|
271
|
+
}>;
|
|
231
272
|
/** Clear an alias. A no-op, not an error, when none is set. */
|
|
232
273
|
deleteAliasEntryID(namespace: string, key: string): Promise<void>;
|
|
274
|
+
/**
|
|
275
|
+
* Clear an alias only while it still points at `entryID`. A concurrent rebind
|
|
276
|
+
* that repaired the pointer onto a fresh entry in the gap after the old entry
|
|
277
|
+
* was deleted is left intact; a no-op when the pointer already moved or is unset.
|
|
278
|
+
*/
|
|
279
|
+
deleteAliasIfEntry(namespace: string, key: string, entryID: string): Promise<void>;
|
|
233
280
|
/**
|
|
234
281
|
* The distinct keys currently set within `namespace`. Keys stay opaque here —
|
|
235
282
|
* 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
|
},
|
|
@@ -449,20 +566,144 @@ export function createCredentialStore(db, adapter) {
|
|
|
449
566
|
const row = await db.selectFrom('kubun_credential_aliases').select('entry_id').where('namespace', '=', namespace).where('alias_key', '=', key).executeTakeFirst();
|
|
450
567
|
return row?.entry_id ?? null;
|
|
451
568
|
},
|
|
569
|
+
async aliasNamespaceForEntry (entryID) {
|
|
570
|
+
const row = await db.selectFrom('kubun_credential_aliases').select('namespace').where('entry_id', '=', entryID).executeTakeFirst();
|
|
571
|
+
return row?.namespace ?? null;
|
|
572
|
+
},
|
|
452
573
|
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({
|
|
574
|
+
await gatedIfAmbient(()=>db.insertInto('kubun_credential_aliases').values({
|
|
575
|
+
namespace,
|
|
576
|
+
alias_key: key,
|
|
461
577
|
entry_id: entryID
|
|
462
|
-
})).
|
|
578
|
+
}).onConflict((oc)=>oc.columns([
|
|
579
|
+
'namespace',
|
|
580
|
+
'alias_key'
|
|
581
|
+
]).doUpdateSet({
|
|
582
|
+
entry_id: entryID
|
|
583
|
+
})).execute());
|
|
584
|
+
},
|
|
585
|
+
async bindAliasIfAbsent ({ namespace, aliasKey, entryID, expectedKeyID }) {
|
|
586
|
+
// Entry-existence-conditioned insert: the alias row is written only in the same
|
|
587
|
+
// statement that confirms the entry exists. ON CONFLICT DO NOTHING fires for either
|
|
588
|
+
// the (namespace, alias_key) PK or the UNIQUE(entry_id); the read-back names which.
|
|
589
|
+
//
|
|
590
|
+
// The table has no FK to entries (SQLite ships with foreign_keys OFF, so a declared
|
|
591
|
+
// FK would be unenforced there). The INSERT...SELECT closes the race on any
|
|
592
|
+
// single-writer store; deleteCredential removes the entry before the pointer so a
|
|
593
|
+
// bind racing a delete refuses cleanly rather than dangling. A pointer that still
|
|
594
|
+
// outlives its entry (a Postgres delete committing mid-statement) is recovered two
|
|
595
|
+
// ways: getCredential/getCredentialRef resolve a missing entry to null, and the
|
|
596
|
+
// repair path below repoints a dangling pointer to a fresh, present entry.
|
|
597
|
+
const tryInsert = async ()=>{
|
|
598
|
+
const inserted = await gatedIfAmbient(()=>db.insertInto('kubun_credential_aliases').columns([
|
|
599
|
+
'namespace',
|
|
600
|
+
'alias_key',
|
|
601
|
+
'entry_id'
|
|
602
|
+
]).expression((eb)=>eb.selectFrom('kubun_credential_entries').where('entry_id', '=', entryID)// Same-statement key gate (see doc): bind only while the entry
|
|
603
|
+
// still belongs to expectedKeyID, so a concurrent cross-key move
|
|
604
|
+
// cannot slip an alias onto an entry that has left this key.
|
|
605
|
+
.$if(expectedKeyID != null, (qb)=>qb.where('key_id', '=', expectedKeyID)).select((eb2)=>[
|
|
606
|
+
eb2.val(namespace).as('namespace'),
|
|
607
|
+
eb2.val(aliasKey).as('alias_key'),
|
|
608
|
+
eb2.ref('entry_id').as('entry_id')
|
|
609
|
+
])).onConflict((oc)=>oc.doNothing()).returning('entry_id').executeTakeFirst());
|
|
610
|
+
return inserted != null;
|
|
611
|
+
};
|
|
612
|
+
const aliasAt = async ()=>{
|
|
613
|
+
const row = await db.selectFrom('kubun_credential_aliases').select('entry_id').where('namespace', '=', namespace).where('alias_key', '=', aliasKey).executeTakeFirst();
|
|
614
|
+
return row?.entry_id ?? null;
|
|
615
|
+
};
|
|
616
|
+
const entryExists = async (id)=>{
|
|
617
|
+
const row = await db.selectFrom('kubun_credential_entries').select('entry_id').where('entry_id', '=', id).executeTakeFirst();
|
|
618
|
+
return row != null;
|
|
619
|
+
};
|
|
620
|
+
// Repoint this alias to entryID, but ONLY while its stored pointer is still
|
|
621
|
+
// dangling (its entry row absent), so a concurrently-installed valid binding is
|
|
622
|
+
// never overwritten — the no-repoint guarantee. A UNIQUE(entry_id) clash (entryID
|
|
623
|
+
// already aliased elsewhere) must not abort an ambient Postgres transaction, so a
|
|
624
|
+
// caller-tx write is fenced in a savepoint and the clash mapped to conflict:'entry'.
|
|
625
|
+
const repairDangling = async (observedExisting)=>{
|
|
626
|
+
const doUpdate = async ()=>{
|
|
627
|
+
const res = await db.updateTable('kubun_credential_aliases').set({
|
|
628
|
+
entry_id: entryID
|
|
629
|
+
}).where('namespace', '=', namespace).where('alias_key', '=', aliasKey)// Compare-and-swap on the pointer: repoint only while it still holds the
|
|
630
|
+
// dangling id we observed. If a concurrent transaction repointed it onto a
|
|
631
|
+
// freshly created entry, this matches zero rows — under Postgres READ
|
|
632
|
+
// COMMITTED the re-checked current row carries the new id, so a valid binding
|
|
633
|
+
// is never overwritten even when that new entry is outside our snapshot.
|
|
634
|
+
.where('entry_id', '=', observedExisting)// The observed pointer must still be dangling (its entry absent)...
|
|
635
|
+
.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
|
|
636
|
+
// pointer even if the target is deleted between the pre-check and here.
|
|
637
|
+
// When key-gated, the target must also still belong to expectedKeyID, so a
|
|
638
|
+
// repair cannot repoint onto an entry that has moved to another key.
|
|
639
|
+
.where((eb)=>eb.exists(eb.selectFrom('kubun_credential_entries as t').select(sql`1`.as('one')).where('t.entry_id', '=', entryID).$if(expectedKeyID != null, (qb)=>qb.where('t.key_id', '=', expectedKeyID)))).executeTakeFirst();
|
|
640
|
+
return (res.numUpdatedRows ?? 0n) > 0n;
|
|
641
|
+
};
|
|
642
|
+
if (!db.isTransaction) {
|
|
643
|
+
try {
|
|
644
|
+
return await doUpdate();
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (isEntryAliasUniqueViolation(error)) return 'entry-clash';
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return await withAmbientWrite(async ()=>{
|
|
651
|
+
await sql`SAVEPOINT kubun_cred_bind_repair`.execute(db);
|
|
652
|
+
try {
|
|
653
|
+
const repaired = await doUpdate();
|
|
654
|
+
await sql`RELEASE SAVEPOINT kubun_cred_bind_repair`.execute(db);
|
|
655
|
+
return repaired;
|
|
656
|
+
} catch (error) {
|
|
657
|
+
await sql`ROLLBACK TO SAVEPOINT kubun_cred_bind_repair`.execute(db);
|
|
658
|
+
await sql`RELEASE SAVEPOINT kubun_cred_bind_repair`.execute(db);
|
|
659
|
+
if (isEntryAliasUniqueViolation(error)) return 'entry-clash';
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
};
|
|
664
|
+
if (await tryInsert()) return 'inserted';
|
|
665
|
+
if (!await entryExists(entryID)) return 'entry-missing';
|
|
666
|
+
const existing = await aliasAt();
|
|
667
|
+
// PK free but the insert did not fire and the entry exists → the caller's entry is
|
|
668
|
+
// already aliased under another name (UNIQUE(entry_id)).
|
|
669
|
+
if (existing == null) return {
|
|
670
|
+
conflict: 'entry'
|
|
671
|
+
};
|
|
672
|
+
if (existing === entryID) return {
|
|
673
|
+
conflict: 'alias',
|
|
674
|
+
existing
|
|
675
|
+
};
|
|
676
|
+
// PK held by a different entry; repair only if that pointer is dangling.
|
|
677
|
+
if (await entryExists(existing)) return {
|
|
678
|
+
conflict: 'alias',
|
|
679
|
+
existing
|
|
680
|
+
};
|
|
681
|
+
const repaired = await repairDangling(existing);
|
|
682
|
+
if (repaired === true) return 'inserted';
|
|
683
|
+
if (repaired === 'entry-clash') return {
|
|
684
|
+
conflict: 'entry'
|
|
685
|
+
};
|
|
686
|
+
// The pointer stopped being dangling under us, or the target vanished (a
|
|
687
|
+
// concurrent rebind, repair, or delete). Reclassify against the current state,
|
|
688
|
+
// re-checking the target so a target that disappeared reports entry-missing
|
|
689
|
+
// rather than a spurious entry conflict.
|
|
690
|
+
if (await tryInsert()) return 'inserted';
|
|
691
|
+
if (!await entryExists(entryID)) return 'entry-missing';
|
|
692
|
+
const now = await aliasAt();
|
|
693
|
+
if (now == null) return {
|
|
694
|
+
conflict: 'entry'
|
|
695
|
+
};
|
|
696
|
+
if (now === entryID) return 'inserted';
|
|
697
|
+
return {
|
|
698
|
+
conflict: 'alias',
|
|
699
|
+
existing: now
|
|
700
|
+
};
|
|
463
701
|
},
|
|
464
702
|
async deleteAliasEntryID (namespace, key) {
|
|
465
|
-
await db.deleteFrom('kubun_credential_aliases').where('namespace', '=', namespace).where('alias_key', '=', key).execute();
|
|
703
|
+
await gatedIfAmbient(()=>db.deleteFrom('kubun_credential_aliases').where('namespace', '=', namespace).where('alias_key', '=', key).execute());
|
|
704
|
+
},
|
|
705
|
+
async deleteAliasIfEntry (namespace, key, entryID) {
|
|
706
|
+
await gatedIfAmbient(()=>db.deleteFrom('kubun_credential_aliases').where('namespace', '=', namespace).where('alias_key', '=', key).where('entry_id', '=', entryID).execute());
|
|
466
707
|
},
|
|
467
708
|
async listAliasKeys (namespace) {
|
|
468
709
|
const rows = await db.selectFrom('kubun_credential_aliases').select('alias_key').distinct().where('namespace', '=', namespace).execute();
|
|
@@ -558,7 +799,7 @@ export function createCredentialStore(db, adapter) {
|
|
|
558
799
|
const owner = normalizeDID(params.ownerDID);
|
|
559
800
|
const recipient = normalizeDID(params.recipientDID);
|
|
560
801
|
const grantor = normalizeDID(params.grantorDID);
|
|
561
|
-
await
|
|
802
|
+
await writeAmbient(db, async (tx)=>{
|
|
562
803
|
await tx.updateTable('kubun_credential_manifests').set((eb)=>({
|
|
563
804
|
epoch: eb.ref('kubun_credential_manifests.epoch')
|
|
564
805
|
})).where('owner_did', '=', owner).where('recipient_did', '=', recipient).where('grantor_did', '=', grantor).execute();
|
|
@@ -617,7 +858,7 @@ export function createCredentialStore(db, adapter) {
|
|
|
617
858
|
async beginProvisioningAttempt (params) {
|
|
618
859
|
const ownerDid = normalizeDID(params.ownerDID);
|
|
619
860
|
const grantorDid = normalizeDID(params.grantorDID);
|
|
620
|
-
await
|
|
861
|
+
await writeAmbient(db, async (tx)=>{
|
|
621
862
|
// Ensure the expectation floor row exists, idempotent at floor 0.
|
|
622
863
|
await tx.insertInto('kubun_credential_provisioning_expectations').values({
|
|
623
864
|
owner_did: ownerDid,
|
|
@@ -640,7 +881,7 @@ export function createCredentialStore(db, adapter) {
|
|
|
640
881
|
status: 'retry',
|
|
641
882
|
minEpoch: params.heldEpoch
|
|
642
883
|
};
|
|
643
|
-
await
|
|
884
|
+
await writeAmbient(db, async (tx)=>{
|
|
644
885
|
if (params.epoch > params.heldEpoch) {
|
|
645
886
|
// Phantom-attempt guard: if the id names no live attempt, move
|
|
646
887
|
// 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();
|