@kubun/store-credential 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.js CHANGED
@@ -1,985 +1,41 @@
1
- import { normalizeDID } from '@kokuin/token';
2
- import { sql } from 'kysely';
1
+ import { createAliasesApi } from './api-aliases.js';
2
+ import { createEntriesApi } from './api-entries.js';
3
+ import { createKeysApi } from './api-keys.js';
4
+ import { createProvisioningApi } from './api-provisioning.js';
5
+ import { createRecordsApi } from './api-records.js';
6
+ import { createStoreContext } from './store-context.js';
3
7
  /**
4
8
  * `refreshSeq` sentinel for a delegation chain that has no future refresh —
5
9
  * larger than any real ms timestamp, so a bounded refresh can never overtake
6
10
  * it. Consumed by `@kubun/plugin-p2p`.
7
11
  */ export const UNBOUNDED_REFRESH_SEQ = 8_640_000_000_000_000;
8
- /**
9
- * Run `write` as one atomic unit.
10
- *
11
- * A row and the op that speaks for it must land together or not at all: a crash
12
- * between a delete and its tombstone loses the deletion, and one between a put
13
- * and its op leaves a row nothing authenticates. Kysely rejects a nested
14
- * `transaction()`, and an ambient one already provides the atomicity, so inside
15
- * a caller's transaction the statements run inline.
16
- */ async function writeAtomically(db, write) {
17
- if (db.isTransaction) {
18
- await write(db);
19
- return;
20
- }
21
- await db.transaction().execute(async (tx)=>{
22
- await write(tx);
12
+ export function createCredentialStore(db, adapter, advisoryIDForKey) {
13
+ const ctx = createStoreContext({
14
+ db,
15
+ adapter,
16
+ advisoryIDForKey,
17
+ getAPI: ()=>api
23
18
  });
24
- }
25
- /**
26
- * The strictly-greater-`refreshSeq` upsert shared by {@link CredentialStoreAPI.putWrappingAuthority}
27
- * and `putWrapping`'s own-transaction variant — one predicate, so the two entry
28
- * points cannot drift on what "newer" means.
29
- */ async function upsertWrappingAuthority(db, params) {
30
- const values = {
31
- wrapping_id: params.wrappingID,
32
- authority_token: params.envelope,
33
- refresh_seq: params.refreshSeq
34
- };
35
- await db.insertInto('kubun_credential_wrapping_authority').values(values).onConflict((oc)=>oc.column('wrapping_id').doUpdateSet({
36
- authority_token: values.authority_token,
37
- refresh_seq: values.refresh_seq
38
- }).where('kubun_credential_wrapping_authority.refresh_seq', '<', values.refresh_seq)).execute();
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
- }
61
- async function writeOp(params) {
62
- const { db, subjectKind, subjectID, op, deleted, allowKeyMove = false } = params;
63
- const values = {
64
- subject_kind: subjectKind,
65
- subject_id: subjectID,
66
- key_id: op.keyID,
67
- author_did: op.authorDID,
68
- hlc: op.hlc,
69
- op_jwt: op.opJWT,
70
- op_hash: op.opHash,
71
- branch_id: op.branchID,
72
- deleted
73
- };
74
- const result = await db.insertInto('kubun_credential_ops').values(values).onConflict((oc)=>{
75
- const update = oc.columns([
76
- 'subject_kind',
77
- 'subject_id'
78
- ]).doUpdateSet({
79
- key_id: values.key_id,
80
- author_did: values.author_did,
81
- hlc: values.hlc,
82
- op_jwt: values.op_jwt,
83
- op_hash: values.op_hash,
84
- branch_id: values.branch_id,
85
- deleted: values.deleted
86
- })// Ownership and the LWW comparison live in the conflict statement so
87
- // concurrent replicas cannot both pass a preceding read.
88
- .where((eb)=>eb.or([
89
- eb('kubun_credential_ops.hlc', '<', values.hlc),
90
- eb.and([
91
- eb('kubun_credential_ops.hlc', '=', values.hlc),
92
- eb('kubun_credential_ops.op_hash', '<', values.op_hash)
93
- ]),
94
- eb.and([
95
- eb('kubun_credential_ops.hlc', '=', values.hlc),
96
- eb('kubun_credential_ops.op_hash', '=', values.op_hash),
97
- eb('kubun_credential_ops.deleted', '<=', values.deleted)
98
- ])
99
- ]));
100
- return allowKeyMove ? update : update.where('kubun_credential_ops.key_id', '=', values.key_id);
101
- }).executeTakeFirst();
102
- return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
103
- }
104
- export function createCredentialStore(db, adapter) {
105
- /**
106
- * Write a row and its op together, unless a tombstone already covers it.
107
- *
108
- * A `null` op writes the row on its own: the op is what a writer able to sign
109
- * supplies, and nothing here can mint one.
110
- */ async function writeSubject(params) {
111
- const { subjectKind, subjectID, op, write, accept, allowKeyMove = false } = params;
112
- if (op == null) {
113
- if (accept == null) {
114
- await write(db);
115
- } else {
116
- await writeAmbient(db, async (tx)=>{
117
- if (await accept(tx)) await write(tx);
118
- });
119
- }
120
- return;
121
- }
122
- const ownsTransaction = !db.isTransaction;
123
- try {
124
- await writeAmbient(db, async (tx)=>{
125
- if (!await writeOp({
126
- db: tx,
127
- subjectKind,
128
- subjectID,
129
- op,
130
- deleted: 0,
131
- allowKeyMove
132
- })) return;
133
- // The op conflict serializes concurrent writers before this read. A
134
- // rejected version must roll its provisional op update back too.
135
- if (accept != null && !await accept(tx)) throw OP_REJECTED;
136
- await write(tx);
137
- });
138
- } catch (error) {
139
- if (error === OP_REJECTED && ownsTransaction) return;
140
- throw error;
141
- }
142
- }
143
- async function deleteSubject(params) {
144
- const { subjectKind, subjectID, tombstone, remove } = params;
145
- if (tombstone == null) {
146
- await remove(db);
147
- return;
148
- }
149
- await writeAmbient(db, async (tx)=>{
150
- if (!await writeOp({
151
- db: tx,
152
- subjectKind,
153
- subjectID,
154
- op: tombstone,
155
- deleted: 1
156
- })) return;
157
- await remove(tx);
158
- });
159
- }
160
- async function writeEntry(params, op, allowMove) {
161
- const values = {
162
- entry_id: params.entryID,
163
- key_id: params.keyID,
164
- key_version: params.keyVersion,
165
- iv: adapter.encodeBinary(params.iv),
166
- ciphertext: adapter.encodeBinary(params.ciphertext),
167
- hlc: params.hlc
168
- };
169
- await writeSubject({
170
- subjectKind: 'entry',
171
- subjectID: params.entryID,
172
- op,
173
- write: async (tx)=>{
174
- if (!allowMove) {
175
- const existing = await tx.selectFrom('kubun_credential_entries').select('key_id').where('entry_id', '=', params.entryID).executeTakeFirst();
176
- if (existing != null && existing.key_id !== params.keyID) {
177
- throw new Error(`Credential entry ${params.entryID} already belongs to key ${existing.key_id}`);
178
- }
179
- }
180
- const result = await tx.insertInto('kubun_credential_entries').values(values).onConflict((oc)=>{
181
- const update = oc.column('entry_id').doUpdateSet({
182
- key_id: values.key_id,
183
- key_version: values.key_version,
184
- iv: values.iv,
185
- ciphertext: values.ciphertext,
186
- hlc: values.hlc
187
- });
188
- return allowMove ? update : update.where('kubun_credential_entries.key_id', '=', values.key_id);
189
- }).executeTakeFirst();
190
- if (!allowMove && (result.numInsertedOrUpdatedRows ?? 0n) === 0n) {
191
- throw new Error(`Credential entry ${params.entryID} belongs to another key`);
192
- }
193
- },
194
- allowKeyMove: allowMove
195
- });
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);
19
+ const { gatedIfAmbient, lockKeyRowInTransaction, lockKeyRowsInTransaction } = ctx;
223
20
  const api = {
21
+ isTransaction: db.isTransaction,
224
22
  async transaction (fn) {
225
23
  if (db.isTransaction) {
226
24
  return await fn(api);
227
25
  }
228
- return await db.transaction().execute(async (tx)=>await fn(createCredentialStore(tx, adapter)));
229
- },
230
- async putKey (params, op) {
231
- await writeSubject({
232
- subjectKind: 'key',
233
- subjectID: params.keyID,
234
- op,
235
- write: async (tx)=>{
236
- await tx.insertInto('kubun_credential_keys').values({
237
- key_id: params.keyID,
238
- owner_did: params.ownerDID,
239
- suite: params.suite,
240
- version: params.version,
241
- state: params.state
242
- }).onConflict((oc)=>oc.column('key_id').doUpdateSet({
243
- owner_did: params.ownerDID,
244
- suite: params.suite,
245
- version: params.version,
246
- state: params.state
247
- })).execute();
248
- },
249
- accept: async (tx)=>{
250
- const existing = await tx.selectFrom('kubun_credential_keys').select('version').where('key_id', '=', params.keyID).executeTakeFirst();
251
- return existing == null || params.version >= existing.version;
252
- }
253
- });
254
- },
255
- async getKey (keyID) {
256
- return await db.selectFrom('kubun_credential_keys').selectAll().where('key_id', '=', keyID).executeTakeFirst();
257
- },
258
- async listKeys (ownerDID) {
259
- return await db.selectFrom('kubun_credential_keys').selectAll().where('owner_did', '=', ownerDID).orderBy('created_at', 'asc').orderBy('key_id', 'asc').execute();
260
- },
261
- async setKeyState (keyID, state, op) {
262
- await writeSubject({
263
- subjectKind: 'key',
264
- subjectID: keyID,
265
- op,
266
- write: async (tx)=>{
267
- await tx.updateTable('kubun_credential_keys').set({
268
- state
269
- }).where('key_id', '=', keyID).execute();
270
- }
271
- });
272
- },
273
- async setKeyVersion (keyID, version, op) {
274
- await writeSubject({
275
- subjectKind: 'key',
276
- subjectID: keyID,
277
- op,
278
- write: async (tx)=>{
279
- await tx.updateTable('kubun_credential_keys').set({
280
- version
281
- }).where('key_id', '=', keyID).execute();
282
- },
283
- accept: async (tx)=>{
284
- const existing = await tx.selectFrom('kubun_credential_keys').select('version').where('key_id', '=', keyID).executeTakeFirst();
285
- return existing == null || version >= existing.version;
286
- }
287
- });
288
- },
289
- async deleteKey (keyID, tombstone) {
290
- await deleteSubject({
291
- subjectKind: 'key',
292
- subjectID: keyID,
293
- tombstone,
294
- remove: async (tx)=>{
295
- await tx.deleteFrom('kubun_credential_keys').where('key_id', '=', keyID).execute();
296
- }
297
- });
298
- },
299
- async recordKeyBranch (params) {
300
- await db.insertInto('kubun_credential_key_branches').values({
301
- key_id: params.keyID,
302
- key_version: params.keyVersion,
303
- op_hash: params.opHash,
304
- author_did: params.authorDID,
305
- hlc: params.hlc,
306
- op_jwt: params.opJWT,
307
- branch_id: params.branchID
308
- }).onConflict((oc)=>oc.columns([
309
- 'key_id',
310
- 'key_version',
311
- 'op_hash'
312
- ]).doNothing()).execute();
313
- },
314
- async listKeyBranches (keyID, keyVersion) {
315
- return await db.selectFrom('kubun_credential_key_branches').selectAll().where('key_id', '=', keyID).where('key_version', '=', keyVersion).orderBy('branch_id', 'asc').execute();
316
- },
317
- async putWrapping (params, op, authority) {
318
- const values = {
319
- wrapping_id: params.wrappingID,
320
- key_id: params.keyID,
321
- key_version: params.keyVersion,
322
- factors: adapter.encodeJSON(params.factors),
323
- iv: adapter.encodeBinary(params.iv),
324
- wrapped_key: adapter.encodeBinary(params.wrappedKey),
325
- recipient_did: params.recipientDID ?? null
326
- };
327
- await writeSubject({
328
- subjectKind: 'wrapping',
329
- subjectID: params.wrappingID,
330
- op,
331
- write: async (tx)=>{
332
- const existing = await tx.selectFrom('kubun_credential_wrappings').select('key_id').where('wrapping_id', '=', params.wrappingID).executeTakeFirst();
333
- if (existing != null && existing.key_id !== params.keyID) {
334
- throw new Error(`Credential wrapping ${params.wrappingID} already belongs to key ${existing.key_id}`);
335
- }
336
- const result = await tx.insertInto('kubun_credential_wrappings').values(values).onConflict((oc)=>oc.column('wrapping_id').doUpdateSet({
337
- key_id: values.key_id,
338
- key_version: values.key_version,
339
- factors: values.factors,
340
- iv: values.iv,
341
- wrapped_key: values.wrapped_key,
342
- recipient_did: values.recipient_did
343
- }).where('kubun_credential_wrappings.key_id', '=', values.key_id)).executeTakeFirst();
344
- if ((result.numInsertedOrUpdatedRows ?? 0n) === 0n) {
345
- throw new Error(`Credential wrapping ${params.wrappingID} belongs to another key`);
346
- }
347
- // Runs only after `writeOp` wins its LWW race, so a superseded
348
- // wrapping never persists an envelope it didn't actually establish.
349
- if (authority != null) {
350
- await upsertWrappingAuthority(tx, {
351
- wrappingID: params.wrappingID,
352
- envelope: authority.envelope,
353
- refreshSeq: authority.refreshSeq
354
- });
355
- }
356
- }
357
- });
358
- },
359
- async getWrapping (wrappingID) {
360
- return await db.selectFrom('kubun_credential_wrappings').selectAll().where('wrapping_id', '=', wrappingID).executeTakeFirst();
361
- },
362
- async listWrappings (keyID, keyVersion) {
363
- let query = db.selectFrom('kubun_credential_wrappings').selectAll().where('key_id', '=', keyID);
364
- if (keyVersion != null) {
365
- query = query.where('key_version', '=', keyVersion);
366
- }
367
- return await query.orderBy('created_at', 'asc').orderBy('wrapping_id', 'asc').execute();
368
- },
369
- async listWrappingsWithBranch (keyID, keyVersion) {
370
- // LEFT JOIN so a wrapping with no op row still lists — its branch is the
371
- // empty label. The ops PK is `(subject_kind, subject_id)`, so a wrapping
372
- // matches at most one op and the join never fans out.
373
- let query = db.selectFrom('kubun_credential_wrappings as w').leftJoin('kubun_credential_ops as o', (join)=>join.onRef('o.subject_id', '=', 'w.wrapping_id').on('o.subject_kind', '=', 'wrapping')).selectAll('w').select('o.branch_id as branch_id').where('w.key_id', '=', keyID);
374
- if (keyVersion != null) {
375
- query = query.where('w.key_version', '=', keyVersion);
376
- }
377
- const rows = await query.orderBy('w.created_at', 'asc').orderBy('w.wrapping_id', 'asc').execute();
378
- return rows.map((row)=>({
379
- ...row,
380
- branch_id: row.branch_id ?? ''
381
- }));
382
- },
383
- async deleteWrapping (wrappingID, tombstone) {
384
- await deleteSubject({
385
- subjectKind: 'wrapping',
386
- subjectID: wrappingID,
387
- tombstone,
388
- remove: async (tx)=>{
389
- await tx.deleteFrom('kubun_credential_wrappings').where('wrapping_id', '=', wrappingID).execute();
390
- }
391
- });
392
- },
393
- async deleteWrappingsBelowVersion (keyID, keyVersion, tombstones) {
394
- const remove = async (tx)=>{
395
- await tx.deleteFrom('kubun_credential_wrappings').where('key_id', '=', keyID).where('key_version', '<', keyVersion).execute();
396
- };
397
- if (tombstones == null) {
398
- await remove(db);
399
- return;
400
- }
401
- await writeAmbient(db, async (tx)=>{
402
- for (const tombstone of tombstones){
403
- if (!await writeOp({
404
- db: tx,
405
- subjectKind: 'wrapping',
406
- subjectID: tombstone.subjectID,
407
- op: tombstone,
408
- deleted: 1
409
- })) {
410
- continue;
411
- }
412
- await tx.deleteFrom('kubun_credential_wrappings').where('wrapping_id', '=', tombstone.subjectID).where('key_id', '=', keyID).where('key_version', '<', keyVersion).execute();
413
- }
414
- });
415
- },
416
- async putEntry (params, op) {
417
- await writeEntry(params, op, false);
418
- },
419
- async moveEntry (params, op) {
420
- await writeEntry(params, op, true);
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
- },
492
- async getEntry (entryID) {
493
- return await db.selectFrom('kubun_credential_entries').selectAll().where('entry_id', '=', entryID).executeTakeFirst();
494
- },
495
- async getEntryWithProvenance (entryID) {
496
- return await db.selectFrom('kubun_credential_entries as e').leftJoin('kubun_credential_ops as o', (join)=>join.onRef('o.subject_id', '=', 'e.entry_id').on('o.subject_kind', '=', 'entry')).selectAll('e').select('o.author_did as author_did').where('e.entry_id', '=', entryID).executeTakeFirst();
26
+ return await db.transaction().execute(async (tx)=>await fn(createCredentialStore(tx, adapter, advisoryIDForKey)));
497
27
  },
498
- async listEntries (keyID) {
499
- return await db.selectFrom('kubun_credential_entries').selectAll().where('key_id', '=', keyID).orderBy('created_at', 'asc').orderBy('entry_id', 'asc').execute();
28
+ async lockKeyRow (keyID) {
29
+ await gatedIfAmbient(async ()=>await lockKeyRowInTransaction(db, keyID));
500
30
  },
501
- async deleteEntry (entryID, tombstone) {
502
- await deleteSubject({
503
- subjectKind: 'entry',
504
- subjectID: entryID,
505
- tombstone,
506
- remove: async (tx)=>{
507
- await tx.deleteFrom('kubun_credential_entries').where('entry_id', '=', entryID).execute();
508
- }
509
- });
31
+ async lockKeyRows (keyIDs) {
32
+ await gatedIfAmbient(async ()=>await lockKeyRowsInTransaction(db, keyIDs));
510
33
  },
511
- async getMaxEntryHLC () {
512
- const row = await db.selectFrom('kubun_credential_entries').select((eb)=>eb.fn.max('hlc').as('max_hlc')).executeTakeFirst();
513
- return row?.max_hlc ?? null;
514
- },
515
- async getOp (subjectKind, subjectID) {
516
- return await db.selectFrom('kubun_credential_ops').selectAll().where('subject_kind', '=', subjectKind).where('subject_id', '=', subjectID).executeTakeFirst();
517
- },
518
- async listOps (keyID) {
519
- return await scanOps(db, keyID);
520
- },
521
- async listOpsForRequester (rawRequesterDID, keyID) {
522
- const requesterDID = normalizeDID(rawRequesterDID);
523
- // Recipient membership comes from the wrappings table, not the ops table: a
524
- // rotation deletes a superseded wrapping's row but keeps its tombstone, and
525
- // a former recipient of an opens-nothing wrapping is no longer a recipient.
526
- // recipient_did is stored un-normalized, so fold at the compare.
527
- const wrappings = await db.selectFrom('kubun_credential_wrappings').select([
528
- 'wrapping_id',
529
- 'key_id',
530
- 'recipient_did'
531
- ]).where('recipient_did', 'is not', null).execute();
532
- const recipientKeyIDs = new Set();
533
- const requesterWrappingIDs = new Set();
534
- for (const wrapping of wrappings){
535
- if (wrapping.recipient_did != null && normalizeDID(wrapping.recipient_did) === requesterDID) {
536
- recipientKeyIDs.add(wrapping.key_id);
537
- requesterWrappingIDs.add(wrapping.wrapping_id);
538
- }
539
- }
540
- const ops = await scanOps(db, keyID);
541
- // A wrapping's ciphertext is usable only by its addressee, so its op travels
542
- // only to the DID it names — a co-recipient's wrapping stays withheld. A
543
- // key's row and its entries travel to any recipient of the key. A wrapping
544
- // tombstone names no addressee once its row is gone, so it cannot route
545
- // here; revocation propagation is a separate concern.
546
- return ops.filter((op)=>{
547
- if (op.subject_kind === 'wrapping') {
548
- return requesterWrappingIDs.has(op.subject_id);
549
- }
550
- return recipientKeyIDs.has(op.key_id);
551
- });
552
- },
553
- async listHeldSubjects () {
554
- const rows = await db.selectFrom('kubun_credential_ops').select([
555
- 'subject_kind',
556
- 'subject_id',
557
- 'op_hash'
558
- ]).where('deleted', '=', 0).execute();
559
- return rows.map((row)=>({
560
- subjectKind: row.subject_kind,
561
- subjectID: row.subject_id,
562
- opHash: row.op_hash
563
- }));
564
- },
565
- async getAliasEntryID (namespace, key) {
566
- const row = await db.selectFrom('kubun_credential_aliases').select('entry_id').where('namespace', '=', namespace).where('alias_key', '=', key).executeTakeFirst();
567
- return row?.entry_id ?? null;
568
- },
569
- async setAliasEntryID (namespace, key, entryID) {
570
- await gatedIfAmbient(()=>db.insertInto('kubun_credential_aliases').values({
571
- namespace,
572
- alias_key: key,
573
- entry_id: entryID
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
- };
692
- },
693
- async deleteAliasEntryID (namespace, key) {
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());
698
- },
699
- async listAliasKeys (namespace) {
700
- const rows = await db.selectFrom('kubun_credential_aliases').select('alias_key').distinct().where('namespace', '=', namespace).execute();
701
- return rows.map((row)=>row.alias_key);
702
- },
703
- async putWrappingAuthority (params) {
704
- await upsertWrappingAuthority(db, params);
705
- },
706
- async getWrappingAuthority (wrappingID) {
707
- const row = await db.selectFrom('kubun_credential_wrapping_authority').select([
708
- 'authority_token',
709
- 'refresh_seq'
710
- ]).where('wrapping_id', '=', wrappingID).executeTakeFirst();
711
- if (row == null) return undefined;
712
- return {
713
- envelope: row.authority_token,
714
- refreshSeq: row.refresh_seq
715
- };
716
- },
717
- async nextGrantorSequence (params) {
718
- // Atomic INSERT ... ON CONFLICT DO UPDATE increments in place, so two
719
- // concurrent callers can't observe the same seq. Normalize the tuple
720
- // HERE (not left to callers) so a did:peer:4 recipient's short/long
721
- // forms converge on one counter instead of splitting into two.
722
- const row = await db.insertInto('kubun_credential_grantor_counters').values({
723
- owner_did: normalizeDID(params.ownerDID),
724
- recipient_did: normalizeDID(params.recipientDID),
725
- grantor_did: normalizeDID(params.grantorDID),
726
- seq: 1
727
- }).onConflict((oc)=>oc.columns([
728
- 'owner_did',
729
- 'recipient_did',
730
- 'grantor_did'
731
- ]).doUpdateSet((eb)=>({
732
- seq: eb('kubun_credential_grantor_counters.seq', '+', 1)
733
- }))).returning('seq').executeTakeFirstOrThrow();
734
- return row.seq;
735
- },
736
- async nextProvisioningEpoch (params) {
737
- // Atomic upsert to max(stored, minEpoch) + 1, guaranteeing monotonicity.
738
- // Normalize the tuple HERE, same reasoning as `nextGrantorSequence`.
739
- const ownerDid = normalizeDID(params.ownerDID);
740
- const recipientDid = normalizeDID(params.recipientDID);
741
- const grantorDid = normalizeDID(params.grantorDID);
742
- const floor = Math.max(0, params.minEpoch);
743
- const row = await db.insertInto('kubun_credential_provisioning_epochs').values({
744
- owner_did: ownerDid,
745
- recipient_did: recipientDid,
746
- grantor_did: grantorDid,
747
- epoch: floor + 1
748
- }).onConflict((oc)=>oc.columns([
749
- 'owner_did',
750
- 'recipient_did',
751
- 'grantor_did'
752
- ]).doUpdateSet((eb)=>({
753
- // max(stored, floor) + 1, expressed without a portable GREATEST:
754
- epoch: eb.case().when('kubun_credential_provisioning_epochs.epoch', '<', floor).then(floor + 1).else(eb('kubun_credential_provisioning_epochs.epoch', '+', 1)).end()
755
- }))).returning('epoch').executeTakeFirstOrThrow();
756
- return row.epoch;
757
- },
758
- async putManifest (record, token, delegationTokens) {
759
- const values = {
760
- owner_did: normalizeDID(record.ownerDID),
761
- recipient_did: normalizeDID(record.recipientDID),
762
- grantor_did: normalizeDID(record.grantorDID),
763
- epoch: record.epoch,
764
- sequence: record.sequence,
765
- keys_json: adapter.encodeJSON(record.keys),
766
- manifest_token: token,
767
- delegation_tokens: adapter.encodeJSON(delegationTokens ?? [])
768
- };
769
- await db.insertInto('kubun_credential_manifests').values(values).onConflict((oc)=>oc.columns([
770
- 'owner_did',
771
- 'recipient_did',
772
- 'grantor_did'
773
- ]).doUpdateSet({
774
- epoch: values.epoch,
775
- sequence: values.sequence,
776
- keys_json: values.keys_json,
777
- manifest_token: values.manifest_token,
778
- delegation_tokens: values.delegation_tokens
779
- })// Lexicographic: higher epoch always wins; within an epoch,
780
- // strictly-greater sequence. A stale/replayed lower value is a silent no-op.
781
- .where((eb)=>eb.or([
782
- eb('kubun_credential_manifests.epoch', '<', values.epoch),
783
- eb.and([
784
- eb('kubun_credential_manifests.epoch', '=', values.epoch),
785
- eb('kubun_credential_manifests.sequence', '<', values.sequence)
786
- ])
787
- ]))).execute();
788
- },
789
- async lockManifestRow (params) {
790
- const owner = normalizeDID(params.ownerDID);
791
- const recipient = normalizeDID(params.recipientDID);
792
- const grantor = normalizeDID(params.grantorDID);
793
- await writeAmbient(db, async (tx)=>{
794
- await tx.updateTable('kubun_credential_manifests').set((eb)=>({
795
- epoch: eb.ref('kubun_credential_manifests.epoch')
796
- })).where('owner_did', '=', owner).where('recipient_did', '=', recipient).where('grantor_did', '=', grantor).execute();
797
- });
798
- },
799
- async listManifests (params) {
800
- const ownerDID = normalizeDID(params.ownerDID);
801
- const recipientDID = normalizeDID(params.recipientDID);
802
- const rows = await db.selectFrom('kubun_credential_manifests').select([
803
- 'owner_did',
804
- 'recipient_did',
805
- 'grantor_did',
806
- 'epoch',
807
- 'sequence',
808
- 'keys_json',
809
- 'manifest_token',
810
- 'delegation_tokens'
811
- ]).where('owner_did', '=', ownerDID).where('recipient_did', '=', recipientDID).orderBy('grantor_did', 'asc').execute();
812
- return rows.map((row)=>({
813
- record: {
814
- ownerDID: row.owner_did,
815
- recipientDID: row.recipient_did,
816
- grantorDID: row.grantor_did,
817
- epoch: row.epoch,
818
- sequence: row.sequence,
819
- keys: row.keys_json
820
- },
821
- token: row.manifest_token,
822
- delegationTokens: row.delegation_tokens
823
- }));
824
- },
825
- async listDistributableKeys (params) {
826
- const owner = normalizeDID(params.ownerDID);
827
- const grantor = normalizeDID(params.grantorDID);
828
- // One snapshot: active owner keys joined to wrappings at the key's current version.
829
- const rows = await db.selectFrom('kubun_credential_keys as k').innerJoin('kubun_credential_wrappings as w', (join)=>join.onRef('w.key_id', '=', 'k.key_id').onRef('w.key_version', '=', 'k.version')).where('k.state', '=', 'active').select([
830
- 'k.key_id as keyID',
831
- 'k.version as version',
832
- 'k.owner_did as ownerDID',
833
- 'w.recipient_did as recipientDID'
834
- ]).execute();
835
- const seen = new Set();
836
- const out = [];
837
- for (const row of rows){
838
- if (normalizeDID(row.ownerDID) !== owner) continue;
839
- if (row.recipientDID == null || normalizeDID(row.recipientDID) !== grantor) continue;
840
- if (seen.has(row.keyID)) continue;
841
- seen.add(row.keyID);
842
- out.push({
843
- keyID: row.keyID,
844
- version: row.version
845
- });
846
- }
847
- return out;
848
- },
849
- async beginProvisioningAttempt (params) {
850
- const ownerDid = normalizeDID(params.ownerDID);
851
- const grantorDid = normalizeDID(params.grantorDID);
852
- await writeAmbient(db, async (tx)=>{
853
- // Ensure the expectation floor row exists, idempotent at floor 0.
854
- await tx.insertInto('kubun_credential_provisioning_expectations').values({
855
- owner_did: ownerDid,
856
- grantor_did: grantorDid,
857
- epoch_floor: 0
858
- }).onConflict((oc)=>oc.doNothing()).execute();
859
- // Insert the outstanding attempt row, idempotent on the id.
860
- await tx.insertInto('kubun_credential_provisioning_attempts').values({
861
- owner_did: ownerDid,
862
- grantor_did: grantorDid,
863
- attempt_id: params.attemptID
864
- }).onConflict((oc)=>oc.doNothing()).execute();
865
- });
866
- },
867
- async recordProvisioningAttempt (params) {
868
- const ownerDid = normalizeDID(params.ownerDID);
869
- const grantorDid = normalizeDID(params.grantorDID);
870
- // Single atomic operation: if epoch > heldEpoch, delete attempt and raise floor.
871
- let result = {
872
- status: 'retry',
873
- minEpoch: params.heldEpoch
874
- };
875
- await writeAmbient(db, async (tx)=>{
876
- if (params.epoch > params.heldEpoch) {
877
- // Phantom-attempt guard: if the id names no live attempt, move
878
- // nothing — raising the floor for it would wedge readiness forever.
879
- const existing = await tx.selectFrom('kubun_credential_provisioning_attempts').select('attempt_id').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptID).executeTakeFirst();
880
- if (existing == null) {
881
- return;
882
- }
883
- // Delete the attempt row.
884
- await tx.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptID).execute();
885
- // Raise the floor to max(stored, epoch).
886
- await tx.updateTable('kubun_credential_provisioning_expectations').set((eb)=>({
887
- epoch_floor: eb.case().when('epoch_floor', '<', params.epoch).then(params.epoch).else(eb.ref('epoch_floor')).end()
888
- })).where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).execute();
889
- result = {
890
- status: 'recorded'
891
- };
892
- }
893
- });
894
- return result;
895
- },
896
- async abandonProvisioningAttempt (params) {
897
- const ownerDid = normalizeDID(params.ownerDID);
898
- const grantorDid = normalizeDID(params.grantorDID);
899
- // Delete ONLY the attempt row, never the floor.
900
- await db.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptID).execute();
901
- },
902
- async abandonProvisioningAttempts (params) {
903
- const ownerDid = normalizeDID(params.ownerDID);
904
- const grantorDid = normalizeDID(params.grantorDID);
905
- // Delete every attempt row for the scope, never the floor.
906
- await db.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).execute();
907
- },
908
- async listProvisioningExpectations (params) {
909
- const ownerDid = normalizeDID(params.ownerDID);
910
- // Left join expectations to outstanding attempts, grouped by (owner, grantor).
911
- // Return one row per (owner, grantor) with floor and outstanding count.
912
- const rows = await db.selectFrom('kubun_credential_provisioning_expectations as e').leftJoin('kubun_credential_provisioning_attempts as a', (join)=>join.onRef('a.owner_did', '=', 'e.owner_did').onRef('a.grantor_did', '=', 'e.grantor_did')).select((eb)=>[
913
- 'e.grantor_did as grantor_did',
914
- 'e.epoch_floor as epoch_floor',
915
- eb.fn.count('a.attempt_id').as('outstanding')
916
- ]).where('e.owner_did', '=', ownerDid).groupBy([
917
- 'e.owner_did',
918
- 'e.grantor_did',
919
- 'e.epoch_floor'
920
- ]).orderBy('e.grantor_did', 'asc').execute();
921
- return rows.map((row)=>({
922
- grantorDID: row.grantor_did,
923
- epochFloor: row.epoch_floor,
924
- outstanding: Number(row.outstanding)
925
- }));
926
- },
927
- async listProvisioningExpectationOwners () {
928
- const rows = await db.selectFrom('kubun_credential_provisioning_expectations').select('owner_did').distinct().execute();
929
- const owners = new Set();
930
- for (const row of rows){
931
- owners.add(normalizeDID(row.owner_did));
932
- }
933
- return [
934
- ...owners
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
- };
972
- }
34
+ ...createKeysApi(ctx),
35
+ ...createEntriesApi(ctx),
36
+ ...createRecordsApi(ctx),
37
+ ...createAliasesApi(ctx),
38
+ ...createProvisioningApi(ctx)
973
39
  };
974
40
  return api;
975
41
  }
976
- /**
977
- * The ops leaf scan in stamp order — every one, or one key's. Two devices must
978
- * agree on this order, so it cannot be the insertion order, which is local.
979
- */ async function scanOps(db, keyID) {
980
- let query = db.selectFrom('kubun_credential_ops').selectAll();
981
- if (keyID != null) {
982
- query = query.where('key_id', '=', keyID);
983
- }
984
- return await query.orderBy('hlc', 'asc').orderBy('subject_id', 'asc').execute();
985
- }