@kubun/store-credential 0.17.4 → 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/advisory-locks.d.ts +5 -0
- package/lib/advisory-locks.js +12 -0
- package/lib/api-aliases.d.ts +3 -0
- package/lib/api-aliases.js +58 -0
- package/lib/api-entries.d.ts +3 -0
- package/lib/api-entries.js +157 -0
- package/lib/api-keys.d.ts +3 -0
- package/lib/api-keys.js +249 -0
- package/lib/api-provisioning.d.ts +3 -0
- package/lib/api-provisioning.js +227 -0
- package/lib/api-records.d.ts +3 -0
- package/lib/api-records.js +74 -0
- package/lib/api-types.d.ts +412 -0
- package/lib/api-types.js +14 -0
- package/lib/api.d.ts +5 -448
- package/lib/api.js +24 -977
- package/lib/definition.js +3 -1
- package/lib/index.d.ts +3 -2
- package/lib/index.js +1 -0
- package/lib/migrations.js +62 -36
- package/lib/records.d.ts +206 -0
- package/lib/records.js +905 -0
- package/lib/store-context.d.ts +37 -0
- package/lib/store-context.js +139 -0
- package/lib/tables.d.ts +88 -64
- package/package.json +11 -8
package/lib/api.js
CHANGED
|
@@ -1,994 +1,41 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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();
|
|
497
|
-
},
|
|
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();
|
|
500
|
-
},
|
|
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
|
-
});
|
|
510
|
-
},
|
|
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 aliasNamespaceForEntry (entryID) {
|
|
570
|
-
const row = await db.selectFrom('kubun_credential_aliases').select('namespace').where('entry_id', '=', entryID).executeTakeFirst();
|
|
571
|
-
return row?.namespace ?? null;
|
|
572
|
-
},
|
|
573
|
-
async setAliasEntryID (namespace, key, entryID) {
|
|
574
|
-
await gatedIfAmbient(()=>db.insertInto('kubun_credential_aliases').values({
|
|
575
|
-
namespace,
|
|
576
|
-
alias_key: key,
|
|
577
|
-
entry_id: entryID
|
|
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
|
-
};
|
|
701
|
-
},
|
|
702
|
-
async deleteAliasEntryID (namespace, key) {
|
|
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());
|
|
707
|
-
},
|
|
708
|
-
async listAliasKeys (namespace) {
|
|
709
|
-
const rows = await db.selectFrom('kubun_credential_aliases').select('alias_key').distinct().where('namespace', '=', namespace).execute();
|
|
710
|
-
return rows.map((row)=>row.alias_key);
|
|
711
|
-
},
|
|
712
|
-
async putWrappingAuthority (params) {
|
|
713
|
-
await upsertWrappingAuthority(db, params);
|
|
714
|
-
},
|
|
715
|
-
async getWrappingAuthority (wrappingID) {
|
|
716
|
-
const row = await db.selectFrom('kubun_credential_wrapping_authority').select([
|
|
717
|
-
'authority_token',
|
|
718
|
-
'refresh_seq'
|
|
719
|
-
]).where('wrapping_id', '=', wrappingID).executeTakeFirst();
|
|
720
|
-
if (row == null) return undefined;
|
|
721
|
-
return {
|
|
722
|
-
envelope: row.authority_token,
|
|
723
|
-
refreshSeq: row.refresh_seq
|
|
724
|
-
};
|
|
26
|
+
return await db.transaction().execute(async (tx)=>await fn(createCredentialStore(tx, adapter, advisoryIDForKey)));
|
|
725
27
|
},
|
|
726
|
-
async
|
|
727
|
-
|
|
728
|
-
// concurrent callers can't observe the same seq. Normalize the tuple
|
|
729
|
-
// HERE (not left to callers) so a did:peer:4 recipient's short/long
|
|
730
|
-
// forms converge on one counter instead of splitting into two.
|
|
731
|
-
const row = await db.insertInto('kubun_credential_grantor_counters').values({
|
|
732
|
-
owner_did: normalizeDID(params.ownerDID),
|
|
733
|
-
recipient_did: normalizeDID(params.recipientDID),
|
|
734
|
-
grantor_did: normalizeDID(params.grantorDID),
|
|
735
|
-
seq: 1
|
|
736
|
-
}).onConflict((oc)=>oc.columns([
|
|
737
|
-
'owner_did',
|
|
738
|
-
'recipient_did',
|
|
739
|
-
'grantor_did'
|
|
740
|
-
]).doUpdateSet((eb)=>({
|
|
741
|
-
seq: eb('kubun_credential_grantor_counters.seq', '+', 1)
|
|
742
|
-
}))).returning('seq').executeTakeFirstOrThrow();
|
|
743
|
-
return row.seq;
|
|
28
|
+
async lockKeyRow (keyID) {
|
|
29
|
+
await gatedIfAmbient(async ()=>await lockKeyRowInTransaction(db, keyID));
|
|
744
30
|
},
|
|
745
|
-
async
|
|
746
|
-
|
|
747
|
-
// Normalize the tuple HERE, same reasoning as `nextGrantorSequence`.
|
|
748
|
-
const ownerDid = normalizeDID(params.ownerDID);
|
|
749
|
-
const recipientDid = normalizeDID(params.recipientDID);
|
|
750
|
-
const grantorDid = normalizeDID(params.grantorDID);
|
|
751
|
-
const floor = Math.max(0, params.minEpoch);
|
|
752
|
-
const row = await db.insertInto('kubun_credential_provisioning_epochs').values({
|
|
753
|
-
owner_did: ownerDid,
|
|
754
|
-
recipient_did: recipientDid,
|
|
755
|
-
grantor_did: grantorDid,
|
|
756
|
-
epoch: floor + 1
|
|
757
|
-
}).onConflict((oc)=>oc.columns([
|
|
758
|
-
'owner_did',
|
|
759
|
-
'recipient_did',
|
|
760
|
-
'grantor_did'
|
|
761
|
-
]).doUpdateSet((eb)=>({
|
|
762
|
-
// max(stored, floor) + 1, expressed without a portable GREATEST:
|
|
763
|
-
epoch: eb.case().when('kubun_credential_provisioning_epochs.epoch', '<', floor).then(floor + 1).else(eb('kubun_credential_provisioning_epochs.epoch', '+', 1)).end()
|
|
764
|
-
}))).returning('epoch').executeTakeFirstOrThrow();
|
|
765
|
-
return row.epoch;
|
|
31
|
+
async lockKeyRows (keyIDs) {
|
|
32
|
+
await gatedIfAmbient(async ()=>await lockKeyRowsInTransaction(db, keyIDs));
|
|
766
33
|
},
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
epoch: record.epoch,
|
|
773
|
-
sequence: record.sequence,
|
|
774
|
-
keys_json: adapter.encodeJSON(record.keys),
|
|
775
|
-
manifest_token: token,
|
|
776
|
-
delegation_tokens: adapter.encodeJSON(delegationTokens ?? [])
|
|
777
|
-
};
|
|
778
|
-
await db.insertInto('kubun_credential_manifests').values(values).onConflict((oc)=>oc.columns([
|
|
779
|
-
'owner_did',
|
|
780
|
-
'recipient_did',
|
|
781
|
-
'grantor_did'
|
|
782
|
-
]).doUpdateSet({
|
|
783
|
-
epoch: values.epoch,
|
|
784
|
-
sequence: values.sequence,
|
|
785
|
-
keys_json: values.keys_json,
|
|
786
|
-
manifest_token: values.manifest_token,
|
|
787
|
-
delegation_tokens: values.delegation_tokens
|
|
788
|
-
})// Lexicographic: higher epoch always wins; within an epoch,
|
|
789
|
-
// strictly-greater sequence. A stale/replayed lower value is a silent no-op.
|
|
790
|
-
.where((eb)=>eb.or([
|
|
791
|
-
eb('kubun_credential_manifests.epoch', '<', values.epoch),
|
|
792
|
-
eb.and([
|
|
793
|
-
eb('kubun_credential_manifests.epoch', '=', values.epoch),
|
|
794
|
-
eb('kubun_credential_manifests.sequence', '<', values.sequence)
|
|
795
|
-
])
|
|
796
|
-
]))).execute();
|
|
797
|
-
},
|
|
798
|
-
async lockManifestRow (params) {
|
|
799
|
-
const owner = normalizeDID(params.ownerDID);
|
|
800
|
-
const recipient = normalizeDID(params.recipientDID);
|
|
801
|
-
const grantor = normalizeDID(params.grantorDID);
|
|
802
|
-
await writeAmbient(db, async (tx)=>{
|
|
803
|
-
await tx.updateTable('kubun_credential_manifests').set((eb)=>({
|
|
804
|
-
epoch: eb.ref('kubun_credential_manifests.epoch')
|
|
805
|
-
})).where('owner_did', '=', owner).where('recipient_did', '=', recipient).where('grantor_did', '=', grantor).execute();
|
|
806
|
-
});
|
|
807
|
-
},
|
|
808
|
-
async listManifests (params) {
|
|
809
|
-
const ownerDID = normalizeDID(params.ownerDID);
|
|
810
|
-
const recipientDID = normalizeDID(params.recipientDID);
|
|
811
|
-
const rows = await db.selectFrom('kubun_credential_manifests').select([
|
|
812
|
-
'owner_did',
|
|
813
|
-
'recipient_did',
|
|
814
|
-
'grantor_did',
|
|
815
|
-
'epoch',
|
|
816
|
-
'sequence',
|
|
817
|
-
'keys_json',
|
|
818
|
-
'manifest_token',
|
|
819
|
-
'delegation_tokens'
|
|
820
|
-
]).where('owner_did', '=', ownerDID).where('recipient_did', '=', recipientDID).orderBy('grantor_did', 'asc').execute();
|
|
821
|
-
return rows.map((row)=>({
|
|
822
|
-
record: {
|
|
823
|
-
ownerDID: row.owner_did,
|
|
824
|
-
recipientDID: row.recipient_did,
|
|
825
|
-
grantorDID: row.grantor_did,
|
|
826
|
-
epoch: row.epoch,
|
|
827
|
-
sequence: row.sequence,
|
|
828
|
-
keys: row.keys_json
|
|
829
|
-
},
|
|
830
|
-
token: row.manifest_token,
|
|
831
|
-
delegationTokens: row.delegation_tokens
|
|
832
|
-
}));
|
|
833
|
-
},
|
|
834
|
-
async listDistributableKeys (params) {
|
|
835
|
-
const owner = normalizeDID(params.ownerDID);
|
|
836
|
-
const grantor = normalizeDID(params.grantorDID);
|
|
837
|
-
// One snapshot: active owner keys joined to wrappings at the key's current version.
|
|
838
|
-
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([
|
|
839
|
-
'k.key_id as keyID',
|
|
840
|
-
'k.version as version',
|
|
841
|
-
'k.owner_did as ownerDID',
|
|
842
|
-
'w.recipient_did as recipientDID'
|
|
843
|
-
]).execute();
|
|
844
|
-
const seen = new Set();
|
|
845
|
-
const out = [];
|
|
846
|
-
for (const row of rows){
|
|
847
|
-
if (normalizeDID(row.ownerDID) !== owner) continue;
|
|
848
|
-
if (row.recipientDID == null || normalizeDID(row.recipientDID) !== grantor) continue;
|
|
849
|
-
if (seen.has(row.keyID)) continue;
|
|
850
|
-
seen.add(row.keyID);
|
|
851
|
-
out.push({
|
|
852
|
-
keyID: row.keyID,
|
|
853
|
-
version: row.version
|
|
854
|
-
});
|
|
855
|
-
}
|
|
856
|
-
return out;
|
|
857
|
-
},
|
|
858
|
-
async beginProvisioningAttempt (params) {
|
|
859
|
-
const ownerDid = normalizeDID(params.ownerDID);
|
|
860
|
-
const grantorDid = normalizeDID(params.grantorDID);
|
|
861
|
-
await writeAmbient(db, async (tx)=>{
|
|
862
|
-
// Ensure the expectation floor row exists, idempotent at floor 0.
|
|
863
|
-
await tx.insertInto('kubun_credential_provisioning_expectations').values({
|
|
864
|
-
owner_did: ownerDid,
|
|
865
|
-
grantor_did: grantorDid,
|
|
866
|
-
epoch_floor: 0
|
|
867
|
-
}).onConflict((oc)=>oc.doNothing()).execute();
|
|
868
|
-
// Insert the outstanding attempt row, idempotent on the id.
|
|
869
|
-
await tx.insertInto('kubun_credential_provisioning_attempts').values({
|
|
870
|
-
owner_did: ownerDid,
|
|
871
|
-
grantor_did: grantorDid,
|
|
872
|
-
attempt_id: params.attemptID
|
|
873
|
-
}).onConflict((oc)=>oc.doNothing()).execute();
|
|
874
|
-
});
|
|
875
|
-
},
|
|
876
|
-
async recordProvisioningAttempt (params) {
|
|
877
|
-
const ownerDid = normalizeDID(params.ownerDID);
|
|
878
|
-
const grantorDid = normalizeDID(params.grantorDID);
|
|
879
|
-
// Single atomic operation: if epoch > heldEpoch, delete attempt and raise floor.
|
|
880
|
-
let result = {
|
|
881
|
-
status: 'retry',
|
|
882
|
-
minEpoch: params.heldEpoch
|
|
883
|
-
};
|
|
884
|
-
await writeAmbient(db, async (tx)=>{
|
|
885
|
-
if (params.epoch > params.heldEpoch) {
|
|
886
|
-
// Phantom-attempt guard: if the id names no live attempt, move
|
|
887
|
-
// nothing — raising the floor for it would wedge readiness forever.
|
|
888
|
-
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();
|
|
889
|
-
if (existing == null) {
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
892
|
-
// Delete the attempt row.
|
|
893
|
-
await tx.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptID).execute();
|
|
894
|
-
// Raise the floor to max(stored, epoch).
|
|
895
|
-
await tx.updateTable('kubun_credential_provisioning_expectations').set((eb)=>({
|
|
896
|
-
epoch_floor: eb.case().when('epoch_floor', '<', params.epoch).then(params.epoch).else(eb.ref('epoch_floor')).end()
|
|
897
|
-
})).where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).execute();
|
|
898
|
-
result = {
|
|
899
|
-
status: 'recorded'
|
|
900
|
-
};
|
|
901
|
-
}
|
|
902
|
-
});
|
|
903
|
-
return result;
|
|
904
|
-
},
|
|
905
|
-
async abandonProvisioningAttempt (params) {
|
|
906
|
-
const ownerDid = normalizeDID(params.ownerDID);
|
|
907
|
-
const grantorDid = normalizeDID(params.grantorDID);
|
|
908
|
-
// Delete ONLY the attempt row, never the floor.
|
|
909
|
-
await db.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptID).execute();
|
|
910
|
-
},
|
|
911
|
-
async abandonProvisioningAttempts (params) {
|
|
912
|
-
const ownerDid = normalizeDID(params.ownerDID);
|
|
913
|
-
const grantorDid = normalizeDID(params.grantorDID);
|
|
914
|
-
// Delete every attempt row for the scope, never the floor.
|
|
915
|
-
await db.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).execute();
|
|
916
|
-
},
|
|
917
|
-
async listProvisioningExpectations (params) {
|
|
918
|
-
const ownerDid = normalizeDID(params.ownerDID);
|
|
919
|
-
// Left join expectations to outstanding attempts, grouped by (owner, grantor).
|
|
920
|
-
// Return one row per (owner, grantor) with floor and outstanding count.
|
|
921
|
-
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)=>[
|
|
922
|
-
'e.grantor_did as grantor_did',
|
|
923
|
-
'e.epoch_floor as epoch_floor',
|
|
924
|
-
eb.fn.count('a.attempt_id').as('outstanding')
|
|
925
|
-
]).where('e.owner_did', '=', ownerDid).groupBy([
|
|
926
|
-
'e.owner_did',
|
|
927
|
-
'e.grantor_did',
|
|
928
|
-
'e.epoch_floor'
|
|
929
|
-
]).orderBy('e.grantor_did', 'asc').execute();
|
|
930
|
-
return rows.map((row)=>({
|
|
931
|
-
grantorDID: row.grantor_did,
|
|
932
|
-
epochFloor: row.epoch_floor,
|
|
933
|
-
outstanding: Number(row.outstanding)
|
|
934
|
-
}));
|
|
935
|
-
},
|
|
936
|
-
async listProvisioningExpectationOwners () {
|
|
937
|
-
const rows = await db.selectFrom('kubun_credential_provisioning_expectations').select('owner_did').distinct().execute();
|
|
938
|
-
const owners = new Set();
|
|
939
|
-
for (const row of rows){
|
|
940
|
-
owners.add(normalizeDID(row.owner_did));
|
|
941
|
-
}
|
|
942
|
-
return [
|
|
943
|
-
...owners
|
|
944
|
-
];
|
|
945
|
-
},
|
|
946
|
-
async refreshKeyOwnerDelegation (params) {
|
|
947
|
-
const values = {
|
|
948
|
-
key_id: params.keyID,
|
|
949
|
-
delegation_tokens: adapter.encodeJSON(params.delegationTokens),
|
|
950
|
-
refresh_seq: params.refreshSeq,
|
|
951
|
-
revision: 1
|
|
952
|
-
};
|
|
953
|
-
const result = await db.insertInto('kubun_credential_key_owner_delegation').values(values).onConflict((oc)=>oc.column('key_id').doUpdateSet({
|
|
954
|
-
delegation_tokens: values.delegation_tokens,
|
|
955
|
-
refresh_seq: values.refresh_seq,
|
|
956
|
-
// Bump so a concurrent replace CAS on the pre-refresh revision fails.
|
|
957
|
-
revision: (eb)=>eb('kubun_credential_key_owner_delegation.revision', '+', 1)
|
|
958
|
-
}).where('kubun_credential_key_owner_delegation.refresh_seq', '<', values.refresh_seq)).executeTakeFirst();
|
|
959
|
-
return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
|
|
960
|
-
},
|
|
961
|
-
async replaceKeyOwnerDelegationIfUnchanged (params) {
|
|
962
|
-
const result = await db.updateTable('kubun_credential_key_owner_delegation').set({
|
|
963
|
-
delegation_tokens: adapter.encodeJSON(params.delegationTokens),
|
|
964
|
-
refresh_seq: params.refreshSeq,
|
|
965
|
-
revision: (eb)=>eb('kubun_credential_key_owner_delegation.revision', '+', 1)
|
|
966
|
-
}).where('key_id', '=', params.keyID).where('revision', '=', params.expectedRevision).executeTakeFirst();
|
|
967
|
-
return (result.numUpdatedRows ?? 0n) > 0n;
|
|
968
|
-
},
|
|
969
|
-
async getKeyOwnerDelegation (keyID) {
|
|
970
|
-
const row = await db.selectFrom('kubun_credential_key_owner_delegation').select([
|
|
971
|
-
'delegation_tokens',
|
|
972
|
-
'refresh_seq',
|
|
973
|
-
'revision'
|
|
974
|
-
]).where('key_id', '=', keyID).executeTakeFirst();
|
|
975
|
-
if (row == null) return undefined;
|
|
976
|
-
return {
|
|
977
|
-
delegationTokens: row.delegation_tokens,
|
|
978
|
-
refreshSeq: row.refresh_seq,
|
|
979
|
-
revision: row.revision
|
|
980
|
-
};
|
|
981
|
-
}
|
|
34
|
+
...createKeysApi(ctx),
|
|
35
|
+
...createEntriesApi(ctx),
|
|
36
|
+
...createRecordsApi(ctx),
|
|
37
|
+
...createAliasesApi(ctx),
|
|
38
|
+
...createProvisioningApi(ctx)
|
|
982
39
|
};
|
|
983
40
|
return api;
|
|
984
41
|
}
|
|
985
|
-
/**
|
|
986
|
-
* The ops leaf scan in stamp order — every one, or one key's. Two devices must
|
|
987
|
-
* agree on this order, so it cannot be the insertion order, which is local.
|
|
988
|
-
*/ async function scanOps(db, keyID) {
|
|
989
|
-
let query = db.selectFrom('kubun_credential_ops').selectAll();
|
|
990
|
-
if (keyID != null) {
|
|
991
|
-
query = query.where('key_id', '=', keyID);
|
|
992
|
-
}
|
|
993
|
-
return await query.orderBy('hlc', 'asc').orderBy('subject_id', 'asc').execute();
|
|
994
|
-
}
|