@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 +72 -1
- package/lib/api.js +290 -17
- package/lib/index.d.ts +2 -2
- package/lib/index.js +1 -1
- package/lib/migrations.js +8 -0
- package/lib/tables.d.ts +16 -0
- package/package.json +3 -3
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
})).
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
25
|
+
"@kubun/db-postgres": "^0.17.1",
|
|
26
26
|
"@testcontainers/postgresql": "^12.1.0"
|
|
27
27
|
},
|
|
28
28
|
"publishConfig": {
|