@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/records.js ADDED
@@ -0,0 +1,905 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { recordClaimDigest, recordDecisionFromVerifiedCopies, recordPayloadDigest, recordPayloadMatches, recordSignerKey, verifySignedRecord } from '@kubun/records';
3
+ import { canonicalStringify, fromB64U, fromUTF, toB64U, toUTF } from '@sozai/codec';
4
+ /** The replicated credential record types, each projected onto one indexed table. */ export const CREDENTIAL_RECORD_TYPES = [
5
+ 'credential.key',
6
+ 'credential.key-branch',
7
+ 'credential.wrapping',
8
+ 'credential.wrapping-authority',
9
+ 'credential.entry',
10
+ 'credential.manifest',
11
+ 'credential.revocation'
12
+ ];
13
+ /** A failure about one credential record, naming it by `(type, id)`. */ export class CredentialRecordError extends Error {
14
+ #type;
15
+ #id;
16
+ constructor(type, id, message, options){
17
+ super(message, options);
18
+ this.name = 'CredentialRecordError';
19
+ this.#type = type;
20
+ this.#id = id;
21
+ }
22
+ get type() {
23
+ return this.#type;
24
+ }
25
+ get id() {
26
+ return this.#id;
27
+ }
28
+ }
29
+ /** Thrown when a record's payload or addressing meta does not have its type's shape. */ export class CredentialRecordPayloadError extends CredentialRecordError {
30
+ constructor(type, id, reason, options){
31
+ super(type, id, `Credential record ${type} ${id}: ${reason}`, options);
32
+ this.name = 'CredentialRecordPayloadError';
33
+ }
34
+ }
35
+ /** Thrown when a record's op does not verify, or does not sign its payload. */ export class CredentialRecordSignatureError extends CredentialRecordError {
36
+ constructor(type, id){
37
+ super(type, id, `Credential record ${type} ${id} is not signed by its op`);
38
+ this.name = 'CredentialRecordSignatureError';
39
+ }
40
+ }
41
+ /**
42
+ * Thrown when a stored record row cannot be read back as a record. A corrupt row is never
43
+ * reported as absent: an absent record admits any first write, so an older op would replace
44
+ * the one the row held.
45
+ */ export class CredentialRecordRowError extends CredentialRecordError {
46
+ constructor(type, id, reason){
47
+ super(type, id, `Stored credential record ${type} ${id} is corrupt: ${reason}`);
48
+ this.name = 'CredentialRecordRowError';
49
+ }
50
+ }
51
+ // Length-prefixed, so no key id can make two pairs spell one id.
52
+ function pairID(first, second) {
53
+ return `${first.length}:${first}/${second}`;
54
+ }
55
+ /** The id of the revocation of `revokedDID` from key `keyID`. */ export function credentialRevocationRecordID(keyID, revokedDID) {
56
+ return pairID(keyID, normalizeDID(revokedDID));
57
+ }
58
+ /** The id of the manifest a grantor records for one recipient, under the record's owner. */ export function credentialManifestRecordID(recipientDID, grantorDID) {
59
+ return pairID(normalizeDID(recipientDID), normalizeDID(grantorDID));
60
+ }
61
+ function isPlainObject(value) {
62
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
63
+ return false;
64
+ }
65
+ const prototype = Object.getPrototypeOf(value);
66
+ return prototype === Object.prototype || prototype === null;
67
+ }
68
+ function isCount(value) {
69
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
70
+ }
71
+ function hasExactKeys(value, keys) {
72
+ const actual = Object.keys(value).sort();
73
+ const expected = [
74
+ ...keys
75
+ ].sort();
76
+ return actual.length === expected.length && actual.every((key, i)=>key === expected[i]);
77
+ }
78
+ function isStringArray(value) {
79
+ return Array.isArray(value) && value.every((item)=>typeof item === 'string');
80
+ }
81
+ class Invalid extends Error {
82
+ }
83
+ function fail(reason) {
84
+ throw new Invalid(reason);
85
+ }
86
+ function readBytes(value, field) {
87
+ if (typeof value !== 'string') fail(`${field} is not a string`);
88
+ let bytes;
89
+ try {
90
+ bytes = fromB64U(value);
91
+ } catch {
92
+ fail(`${field} is not base64url`);
93
+ }
94
+ if (toB64U(bytes) !== value) fail(`${field} is not canonical base64url`);
95
+ return bytes;
96
+ }
97
+ function normalized(value, field) {
98
+ if (typeof value !== 'string') fail(`${field} is not a DID`);
99
+ try {
100
+ return normalizeDID(value);
101
+ } catch {
102
+ fail(`${field} is not a DID`);
103
+ }
104
+ }
105
+ function toWire(type, payload) {
106
+ switch(type){
107
+ case 'credential.wrapping':
108
+ {
109
+ const { factors, iv, wrappedKey } = payload;
110
+ return {
111
+ factors,
112
+ iv: toB64U(iv),
113
+ wrappedKey: toB64U(wrappedKey)
114
+ };
115
+ }
116
+ case 'credential.entry':
117
+ {
118
+ const { iv, ciphertext } = payload;
119
+ return {
120
+ iv: toB64U(iv),
121
+ ciphertext: toB64U(ciphertext)
122
+ };
123
+ }
124
+ default:
125
+ return payload;
126
+ }
127
+ }
128
+ /** The payload bytes of a credential record: canonical JSON, byte strings as base64url. */ export function encodeCredentialRecordPayload(type, payload) {
129
+ return fromUTF(canonicalStringify(toWire(type, payload)));
130
+ }
131
+ function readPayload(type, bytes) {
132
+ let text;
133
+ let parsed;
134
+ try {
135
+ text = toUTF(bytes);
136
+ parsed = JSON.parse(text);
137
+ } catch {
138
+ fail('payload is not JSON');
139
+ }
140
+ if (!isPlainObject(parsed)) fail('payload is not an object');
141
+ if (canonicalStringify(parsed) !== text) fail('payload is not canonical JSON');
142
+ const p = parsed;
143
+ switch(type){
144
+ case 'credential.key':
145
+ if (!hasExactKeys(p, [
146
+ 'suite',
147
+ 'state'
148
+ ])) fail('payload fields');
149
+ if (!isCount(p.suite)) fail('suite');
150
+ if (p.state !== 'active' && p.state !== 'retired') fail('state');
151
+ return p;
152
+ case 'credential.key-branch':
153
+ if (!hasExactKeys(p, [
154
+ 'authorDID',
155
+ 'hlc',
156
+ 'opJWT',
157
+ 'opHash',
158
+ 'suite'
159
+ ])) fail('payload fields');
160
+ for (const field of [
161
+ 'authorDID',
162
+ 'hlc',
163
+ 'opJWT',
164
+ 'opHash'
165
+ ]){
166
+ if (typeof p[field] !== 'string') fail(field);
167
+ }
168
+ if (!isCount(p.suite)) fail('suite');
169
+ return p;
170
+ case 'credential.wrapping':
171
+ {
172
+ if (!hasExactKeys(p, [
173
+ 'factors',
174
+ 'iv',
175
+ 'wrappedKey'
176
+ ])) fail('payload fields');
177
+ const factors = p.factors;
178
+ if (!Array.isArray(factors) || !factors.every((factor)=>isPlainObject(factor) && hasExactKeys(factor, [
179
+ 'kind',
180
+ 'params'
181
+ ]) && typeof factor.kind === 'string' && isPlainObject(factor.params))) {
182
+ fail('factors');
183
+ }
184
+ return {
185
+ factors,
186
+ iv: readBytes(p.iv, 'iv'),
187
+ wrappedKey: readBytes(p.wrappedKey, 'wrappedKey')
188
+ };
189
+ }
190
+ case 'credential.wrapping-authority':
191
+ if (!hasExactKeys(p, [
192
+ 'envelope'
193
+ ]) || typeof p.envelope !== 'string') fail('envelope');
194
+ return p;
195
+ case 'credential.entry':
196
+ if (!hasExactKeys(p, [
197
+ 'iv',
198
+ 'ciphertext'
199
+ ])) fail('payload fields');
200
+ return {
201
+ iv: readBytes(p.iv, 'iv'),
202
+ ciphertext: readBytes(p.ciphertext, 'ciphertext')
203
+ };
204
+ case 'credential.manifest':
205
+ {
206
+ if (!hasExactKeys(p, [
207
+ 'keys',
208
+ 'token',
209
+ 'delegationTokens'
210
+ ])) fail('payload fields');
211
+ const keys = p.keys;
212
+ if (!Array.isArray(keys) || !keys.every((key)=>isPlainObject(key) && hasExactKeys(key, [
213
+ 'keyID',
214
+ 'version'
215
+ ]) && typeof key.keyID === 'string' && isCount(key.version))) {
216
+ fail('keys');
217
+ }
218
+ if (typeof p.token !== 'string') fail('token');
219
+ if (!isStringArray(p.delegationTokens)) fail('delegationTokens');
220
+ return p;
221
+ }
222
+ case 'credential.revocation':
223
+ if (!hasExactKeys(p, [])) fail('payload fields');
224
+ return p;
225
+ }
226
+ }
227
+ function readBranchID(value) {
228
+ if (typeof value !== 'string' || value === '') fail('meta.branchID');
229
+ return value;
230
+ }
231
+ function readMeta(type, id, payloadBytes, m) {
232
+ switch(type){
233
+ case 'credential.key':
234
+ if (m.keyID !== id) fail('meta.keyID is not the record id');
235
+ if (!isCount(m.version)) fail('meta.version');
236
+ return {
237
+ keyID: m.keyID,
238
+ version: m.version
239
+ };
240
+ case 'credential.key-branch':
241
+ if (id !== recordPayloadDigest(payloadBytes)) fail('record id is not the payload digest');
242
+ if (typeof m.keyID !== 'string') fail('meta.keyID');
243
+ if (!isCount(m.keyVersion)) fail('meta.keyVersion');
244
+ if (typeof m.branchID !== 'string') fail('meta.branchID');
245
+ return {
246
+ keyID: m.keyID,
247
+ keyVersion: m.keyVersion,
248
+ branchID: m.branchID
249
+ };
250
+ case 'credential.wrapping':
251
+ if (typeof m.keyID !== 'string') fail('meta.keyID');
252
+ if (!isCount(m.keyVersion)) fail('meta.keyVersion');
253
+ return {
254
+ keyID: m.keyID,
255
+ keyVersion: m.keyVersion,
256
+ branchID: readBranchID(m.branchID),
257
+ recipient: m.recipient == null ? null : normalized(m.recipient, 'meta.recipient')
258
+ };
259
+ case 'credential.wrapping-authority':
260
+ if (m.wrappingID !== id) fail('meta.wrappingID is not the record id');
261
+ if (!isCount(m.refreshSeq)) fail('meta.refreshSeq');
262
+ return {
263
+ wrappingID: m.wrappingID,
264
+ refreshSeq: m.refreshSeq
265
+ };
266
+ case 'credential.entry':
267
+ if (typeof m.keyID !== 'string') fail('meta.keyID');
268
+ if (!isCount(m.keyVersion)) fail('meta.keyVersion');
269
+ return {
270
+ keyID: m.keyID,
271
+ keyVersion: m.keyVersion,
272
+ branchID: readBranchID(m.branchID)
273
+ };
274
+ case 'credential.manifest':
275
+ {
276
+ const recipient = normalized(m.recipient, 'meta.recipient');
277
+ const grantor = normalized(m.grantor, 'meta.grantor');
278
+ if (id !== pairID(recipient, grantor)) fail('record id does not name its recipient and grantor');
279
+ if (!isCount(m.epoch)) fail('meta.epoch');
280
+ if (!isCount(m.sequence)) fail('meta.sequence');
281
+ return {
282
+ recipient,
283
+ grantor,
284
+ epoch: m.epoch,
285
+ sequence: m.sequence
286
+ };
287
+ }
288
+ case 'credential.revocation':
289
+ {
290
+ if (typeof m.keyID !== 'string') fail('meta.keyID');
291
+ const revokedDID = normalized(m.revokedDID, 'meta.revokedDID');
292
+ if (id !== pairID(m.keyID, revokedDID)) fail('record id does not name its key and recipient');
293
+ return {
294
+ keyID: m.keyID,
295
+ revokedDID
296
+ };
297
+ }
298
+ }
299
+ }
300
+ function isCredentialRecordType(type) {
301
+ return CREDENTIAL_RECORD_TYPES.includes(type);
302
+ }
303
+ /**
304
+ * Decode a live credential record's payload and the meta fields that address its indexed
305
+ * row. DIDs the store keys on are returned normalized. A type's authorizer can call this to
306
+ * refuse a record the store would not accept.
307
+ *
308
+ * @throws CredentialRecordPayloadError when the type is not a credential type, the record is
309
+ * a tombstone, or its payload or meta does not have the type's shape.
310
+ */ export function decodeCredentialRecord(record, meta) {
311
+ const { type, id, owner, hlc, payload } = record;
312
+ try {
313
+ if (!isCredentialRecordType(type)) fail('not a credential record type');
314
+ if (payload === null) fail('a tombstone has no payload');
315
+ if (!isPlainObject(meta)) fail('meta is not an object');
316
+ return {
317
+ type,
318
+ id,
319
+ owner,
320
+ hlc,
321
+ meta: readMeta(type, id, payload, meta),
322
+ payload: readPayload(type, payload)
323
+ };
324
+ } catch (error) {
325
+ if (error instanceof Invalid) {
326
+ // biome-ignore lint/style/useErrorCause: the cause rides the fourth argument
327
+ throw new CredentialRecordPayloadError(type, id, error.message, {
328
+ cause: error
329
+ });
330
+ }
331
+ throw error;
332
+ }
333
+ }
334
+ // A tombstone removes only its owner's indexed row.
335
+ async function removeByID(tx, type, owner, id) {
336
+ switch(type){
337
+ case 'credential.key':
338
+ await tx.deleteFrom('kubun_credential_keys').where('owner_did', '=', owner).where('key_id', '=', id).execute();
339
+ return;
340
+ case 'credential.wrapping':
341
+ await tx.deleteFrom('kubun_credential_wrappings').where('owner_did', '=', owner).where('wrapping_id', '=', id).execute();
342
+ return;
343
+ case 'credential.wrapping-authority':
344
+ await tx.deleteFrom('kubun_credential_wrapping_authority').where('owner_did', '=', owner).where('wrapping_id', '=', id).execute();
345
+ return;
346
+ case 'credential.entry':
347
+ await tx.deleteFrom('kubun_credential_entries').where('owner_did', '=', owner).where('entry_id', '=', id).execute();
348
+ return;
349
+ default:
350
+ return;
351
+ }
352
+ }
353
+ function projectionOf(record, adapter) {
354
+ const byID = {
355
+ address: JSON.stringify([
356
+ record.owner,
357
+ record.id
358
+ ]),
359
+ remove: (tx)=>removeByID(tx, record.type, record.owner, record.id)
360
+ };
361
+ switch(record.type){
362
+ case 'credential.key':
363
+ {
364
+ const values = {
365
+ key_id: record.id,
366
+ owner_did: record.owner,
367
+ suite: record.payload.suite,
368
+ version: record.meta.version,
369
+ state: record.payload.state
370
+ };
371
+ return {
372
+ ...byID,
373
+ write: async (tx)=>{
374
+ await tx.insertInto('kubun_credential_keys').values(values).onConflict((oc)=>oc.columns([
375
+ 'owner_did',
376
+ 'key_id'
377
+ ]).doUpdateSet({
378
+ suite: values.suite,
379
+ version: values.version,
380
+ state: values.state
381
+ })).execute();
382
+ }
383
+ };
384
+ }
385
+ case 'credential.key-branch':
386
+ {
387
+ const values = {
388
+ owner_did: record.owner,
389
+ key_id: record.meta.keyID,
390
+ key_version: record.meta.keyVersion,
391
+ op_hash: record.payload.opHash,
392
+ author_did: record.payload.authorDID,
393
+ hlc: record.payload.hlc,
394
+ op_jwt: record.payload.opJWT,
395
+ branch_id: record.meta.branchID
396
+ };
397
+ return {
398
+ address: JSON.stringify([
399
+ values.owner_did,
400
+ values.key_id,
401
+ values.key_version,
402
+ values.op_hash
403
+ ]),
404
+ write: async (tx)=>{
405
+ await tx.insertInto('kubun_credential_key_branches').values(values).onConflict((oc)=>oc.columns([
406
+ 'owner_did',
407
+ 'key_id',
408
+ 'key_version',
409
+ 'op_hash'
410
+ ]).doUpdateSet({
411
+ author_did: values.author_did,
412
+ hlc: values.hlc,
413
+ op_jwt: values.op_jwt,
414
+ branch_id: values.branch_id
415
+ })).execute();
416
+ },
417
+ remove: async (tx)=>{
418
+ await tx.deleteFrom('kubun_credential_key_branches').where('owner_did', '=', values.owner_did).where('key_id', '=', values.key_id).where('key_version', '=', values.key_version).where('op_hash', '=', values.op_hash).execute();
419
+ }
420
+ };
421
+ }
422
+ case 'credential.wrapping':
423
+ {
424
+ const values = {
425
+ owner_did: record.owner,
426
+ wrapping_id: record.id,
427
+ key_id: record.meta.keyID,
428
+ key_version: record.meta.keyVersion,
429
+ factors: adapter.encodeJSON(record.payload.factors),
430
+ iv: adapter.encodeBinary(record.payload.iv),
431
+ wrapped_key: adapter.encodeBinary(record.payload.wrappedKey),
432
+ recipient_did: record.meta.recipient ?? null,
433
+ branch_id: record.meta.branchID
434
+ };
435
+ return {
436
+ ...byID,
437
+ write: async (tx)=>{
438
+ await tx.insertInto('kubun_credential_wrappings').values(values).onConflict((oc)=>oc.columns([
439
+ 'owner_did',
440
+ 'wrapping_id'
441
+ ]).doUpdateSet({
442
+ key_id: values.key_id,
443
+ key_version: values.key_version,
444
+ factors: values.factors,
445
+ iv: values.iv,
446
+ wrapped_key: values.wrapped_key,
447
+ recipient_did: values.recipient_did,
448
+ branch_id: values.branch_id
449
+ })).execute();
450
+ }
451
+ };
452
+ }
453
+ case 'credential.wrapping-authority':
454
+ {
455
+ const values = {
456
+ owner_did: record.owner,
457
+ wrapping_id: record.id,
458
+ authority_token: record.payload.envelope,
459
+ refresh_seq: record.meta.refreshSeq
460
+ };
461
+ return {
462
+ ...byID,
463
+ write: async (tx)=>{
464
+ await tx.insertInto('kubun_credential_wrapping_authority').values(values).onConflict((oc)=>oc.columns([
465
+ 'owner_did',
466
+ 'wrapping_id'
467
+ ]).doUpdateSet({
468
+ authority_token: values.authority_token,
469
+ refresh_seq: values.refresh_seq
470
+ })).execute();
471
+ }
472
+ };
473
+ }
474
+ case 'credential.entry':
475
+ {
476
+ const values = {
477
+ owner_did: record.owner,
478
+ entry_id: record.id,
479
+ key_id: record.meta.keyID,
480
+ key_version: record.meta.keyVersion,
481
+ iv: adapter.encodeBinary(record.payload.iv),
482
+ ciphertext: adapter.encodeBinary(record.payload.ciphertext),
483
+ hlc: record.hlc,
484
+ branch_id: record.meta.branchID
485
+ };
486
+ return {
487
+ ...byID,
488
+ write: async (tx)=>{
489
+ await tx.insertInto('kubun_credential_entries').values(values).onConflict((oc)=>oc.columns([
490
+ 'owner_did',
491
+ 'entry_id'
492
+ ]).doUpdateSet({
493
+ key_id: values.key_id,
494
+ key_version: values.key_version,
495
+ iv: values.iv,
496
+ ciphertext: values.ciphertext,
497
+ hlc: values.hlc,
498
+ branch_id: values.branch_id
499
+ })).execute();
500
+ }
501
+ };
502
+ }
503
+ case 'credential.manifest':
504
+ {
505
+ const values = {
506
+ owner_did: record.owner,
507
+ recipient_did: record.meta.recipient,
508
+ grantor_did: record.meta.grantor,
509
+ epoch: record.meta.epoch,
510
+ sequence: record.meta.sequence,
511
+ keys_json: adapter.encodeJSON(record.payload.keys),
512
+ manifest_token: record.payload.token,
513
+ delegation_tokens: adapter.encodeJSON(record.payload.delegationTokens)
514
+ };
515
+ return {
516
+ address: JSON.stringify([
517
+ values.owner_did,
518
+ values.recipient_did,
519
+ values.grantor_did
520
+ ]),
521
+ write: async (tx)=>{
522
+ await tx.insertInto('kubun_credential_manifests').values(values).onConflict((oc)=>oc.columns([
523
+ 'owner_did',
524
+ 'recipient_did',
525
+ 'grantor_did'
526
+ ]).doUpdateSet({
527
+ epoch: values.epoch,
528
+ sequence: values.sequence,
529
+ keys_json: values.keys_json,
530
+ manifest_token: values.manifest_token,
531
+ delegation_tokens: values.delegation_tokens
532
+ })).execute();
533
+ },
534
+ remove: async (tx)=>{
535
+ await tx.deleteFrom('kubun_credential_manifests').where('owner_did', '=', values.owner_did).where('recipient_did', '=', values.recipient_did).where('grantor_did', '=', values.grantor_did).execute();
536
+ }
537
+ };
538
+ }
539
+ case 'credential.revocation':
540
+ {
541
+ const values = {
542
+ key_id: record.meta.keyID,
543
+ recipient_did: record.meta.revokedDID,
544
+ owner_did: record.owner
545
+ };
546
+ return {
547
+ address: JSON.stringify([
548
+ values.owner_did,
549
+ values.key_id,
550
+ values.recipient_did
551
+ ]),
552
+ write: async (tx)=>{
553
+ await tx.insertInto('kubun_credential_revocations').values(values).onConflict((oc)=>oc.columns([
554
+ 'owner_did',
555
+ 'key_id',
556
+ 'recipient_did'
557
+ ]).doNothing()).execute();
558
+ },
559
+ remove: async (tx)=>{
560
+ await tx.deleteFrom('kubun_credential_revocations').where('owner_did', '=', values.owner_did).where('key_id', '=', values.key_id).where('recipient_did', '=', values.recipient_did).execute();
561
+ }
562
+ };
563
+ }
564
+ }
565
+ }
566
+ function rowFailure(row, reason) {
567
+ throw new CredentialRecordRowError(String(row.type), String(row.id), reason);
568
+ }
569
+ function rowMeta(row) {
570
+ // SQLite hands JSON back as text when the parse plugin could not read it.
571
+ const meta = typeof row.meta === 'string' ? safeParse(row.meta) : row.meta;
572
+ if (!isPlainObject(meta)) rowFailure(row, 'meta is not an object');
573
+ return meta;
574
+ }
575
+ function safeParse(text) {
576
+ try {
577
+ return JSON.parse(text);
578
+ } catch {
579
+ return undefined;
580
+ }
581
+ }
582
+ /**
583
+ * A stored row as the record it holds. The signature is not checked here — apply verifies
584
+ * every stored op it reads — but everything the row repeats must agree with itself.
585
+ *
586
+ * @throws CredentialRecordRowError when it does not.
587
+ */ export function credentialRecordFromRow(row) {
588
+ for (const field of [
589
+ 'type',
590
+ 'id',
591
+ 'owner',
592
+ 'hlc',
593
+ 'op_jwt',
594
+ 'op_hash'
595
+ ]){
596
+ if (typeof row[field] !== 'string' || row[field] === '') rowFailure(row, `${field} is empty`);
597
+ }
598
+ if (row.deleted !== 0 && row.deleted !== 1) rowFailure(row, 'deleted is not 0 or 1');
599
+ const payload = row.payload == null ? null : Uint8Array.from(row.payload);
600
+ if (row.deleted === 1 !== (payload === null)) {
601
+ rowFailure(row, 'a tombstone must have no payload, and a live record one');
602
+ }
603
+ if (recordPayloadDigest(payload) !== row.payload_digest) {
604
+ rowFailure(row, 'payload does not match its digest');
605
+ }
606
+ rowMeta(row);
607
+ return {
608
+ type: row.type,
609
+ id: row.id,
610
+ owner: row.owner,
611
+ hlc: row.hlc,
612
+ payload,
613
+ opJWT: row.op_jwt,
614
+ opHash: row.op_hash
615
+ };
616
+ }
617
+ /** Keep every authenticated entry operation for branch comparison, including a losing op. */ export async function retainCredentialEntryCandidate(tx, record, claim, adapter) {
618
+ if (record.type !== 'credential.entry') return;
619
+ const { keyID, keyVersion, branchID } = claim.meta;
620
+ if (typeof keyID !== 'string' || typeof keyVersion !== 'number') return;
621
+ await tx.insertInto('kubun_credential_entry_candidates').values({
622
+ key_id: keyID,
623
+ owner_did: claim.owner,
624
+ key_version: keyVersion,
625
+ branch_id: typeof branchID === 'string' ? branchID : '',
626
+ entry_id: record.id,
627
+ owner: record.owner,
628
+ hlc: record.hlc,
629
+ op_jwt: record.opJWT,
630
+ op_hash: record.opHash,
631
+ payload: record.payload === null ? null : adapter.encodeBinary(record.payload)
632
+ }).onConflict((oc)=>oc.columns([
633
+ 'owner_did',
634
+ 'entry_id',
635
+ 'op_hash'
636
+ ]).doNothing()).execute();
637
+ }
638
+ /**
639
+ * Store a copy of the selected decision of its `(type, owner, id)`, and
640
+ * bring its indexed row along in the same transaction: a live record writes its row, a
641
+ * tombstone removes the row the previous op projected, and a record whose index address
642
+ * moved removes its old row. Merge decisions are the caller's; this only checks that the
643
+ * record is signed and has its type's shape, before anything is written.
644
+ *
645
+ * @throws CredentialRecordSignatureError when the op does not verify or sign the payload.
646
+ * @throws CredentialRecordPayloadError when a live record does not have its type's shape.
647
+ * @throws CredentialRecordRowError when the row it replaces is corrupt.
648
+ */ export async function putCredentialRecord(params) {
649
+ const { record, adapter, write, methods, lockKey, verifiedCopies } = params;
650
+ if (!isCredentialRecordType(record.type)) {
651
+ throw new CredentialRecordPayloadError(record.type, record.id, 'not a credential record type');
652
+ }
653
+ const type = record.type;
654
+ const op = await verifySignedRecord(record, methods);
655
+ if (op === null || !recordPayloadMatches(record, op.claim)) {
656
+ throw new CredentialRecordSignatureError(record.type, record.id);
657
+ }
658
+ const claim = op.claim;
659
+ const cacheKey = JSON.stringify([
660
+ record.type,
661
+ record.owner,
662
+ record.id
663
+ ]);
664
+ const known = verifiedCopies.get(cacheKey);
665
+ let nextKnown = null;
666
+ const next = claim.deleted ? null : projectionOf(decodeCredentialRecord(record, claim.meta), adapter);
667
+ await write(async (tx)=>{
668
+ const keyID = claim.meta.keyID;
669
+ if (typeof keyID === 'string') await lockKey(tx, keyID);
670
+ const previousRows = await tx.selectFrom('kubun_credential_records').selectAll().where('type', '=', type).where('owner', '=', claim.owner).where('id', '=', record.id).orderBy('op_hash', 'asc').execute();
671
+ let previousRow;
672
+ let stored = null;
673
+ let previousOp = null;
674
+ let firstRowError = null;
675
+ const validRows = new Map();
676
+ for (const row of previousRows){
677
+ let candidate;
678
+ try {
679
+ candidate = credentialRecordFromRow(row);
680
+ } catch (error) {
681
+ if (!(error instanceof CredentialRecordRowError)) throw error;
682
+ firstRowError ??= error;
683
+ continue;
684
+ }
685
+ const knownCopy = known?.get(candidate.opHash);
686
+ const trusted = knownCopy !== undefined && knownCopy.copy.type === candidate.type && knownCopy.copy.id === candidate.id && knownCopy.copy.owner === candidate.owner && knownCopy.copy.opJWT === candidate.opJWT && knownCopy.copy.hlc === candidate.hlc && recordPayloadDigest(knownCopy.copy.payload) === recordPayloadDigest(candidate.payload);
687
+ const verified = trusted ? knownCopy.op : await verifySignedRecord(candidate, methods);
688
+ if (verified === null || !recordPayloadMatches(candidate, verified.claim)) continue;
689
+ validRows.set(row.op_hash, {
690
+ copy: candidate,
691
+ op: verified
692
+ });
693
+ if (previousRow === undefined) {
694
+ previousRow = row;
695
+ stored = candidate;
696
+ previousOp = verified;
697
+ }
698
+ }
699
+ if (previousRow === undefined && firstRowError !== null) throw firstRowError;
700
+ let previous = null;
701
+ // Read back before the liveness check, so a corrupt row fails closed rather than
702
+ // being taken for a tombstone and written over.
703
+ const sameDecision = previousOp !== null && recordClaimDigest(previousOp.claim) === recordClaimDigest(claim) && (typeof previousOp.claim.meta.version === 'string' ? previousOp.claim.meta.version : previousOp.claim.hlc) === (typeof claim.meta.version === 'string' ? claim.meta.version : claim.hlc);
704
+ if (previousRow != null && stored !== null && stored.payload !== null) {
705
+ try {
706
+ previous = projectionOf(decodeCredentialRecord(stored, rowMeta(previousRow)), adapter);
707
+ } catch (error) {
708
+ if (error instanceof CredentialRecordPayloadError) {
709
+ rowFailure(previousRow, error.message);
710
+ }
711
+ throw error;
712
+ }
713
+ }
714
+ const values = {
715
+ type,
716
+ id: record.id,
717
+ owner: record.owner,
718
+ hlc: record.hlc,
719
+ op_jwt: record.opJWT,
720
+ op_hash: record.opHash,
721
+ payload_digest: claim.payloadDigest,
722
+ payload: record.payload == null ? null : adapter.encodeBinary(record.payload),
723
+ deleted: claim.deleted ? 1 : 0,
724
+ meta: adapter.encodeJSON(claim.meta)
725
+ };
726
+ if (!sameDecision) {
727
+ await tx.deleteFrom('kubun_credential_records').where('type', '=', type).where('owner', '=', claim.owner).where('id', '=', record.id).execute();
728
+ } else {
729
+ for (const row of previousRows){
730
+ if (!validRows.has(row.op_hash)) {
731
+ credentialRecordFromRow(row);
732
+ await tx.deleteFrom('kubun_credential_records').where('type', '=', type).where('owner', '=', claim.owner).where('id', '=', record.id).where('op_hash', '=', row.op_hash).execute();
733
+ }
734
+ }
735
+ }
736
+ await tx.insertInto('kubun_credential_records').values(values).onConflict((oc)=>oc.columns([
737
+ 'type',
738
+ 'owner',
739
+ 'id',
740
+ 'op_hash'
741
+ ]).doNothing()).execute();
742
+ if (sameDecision) {
743
+ const incomingSigner = recordSignerKey(op.issuer, record.opJWT);
744
+ const sameSigner = [
745
+ ...validRows
746
+ ].filter(([, value])=>recordSignerKey(value.op.issuer, value.copy.opJWT) === incomingSigner);
747
+ const keepHash = [
748
+ record.opHash,
749
+ ...sameSigner.map(([hash])=>hash)
750
+ ].sort()[0];
751
+ for (const [hash] of sameSigner){
752
+ if (hash !== keepHash) {
753
+ await tx.deleteFrom('kubun_credential_records').where('type', '=', type).where('owner', '=', claim.owner).where('id', '=', record.id).where('op_hash', '=', hash).execute();
754
+ }
755
+ }
756
+ if (record.opHash !== keepHash) {
757
+ await tx.deleteFrom('kubun_credential_records').where('type', '=', type).where('owner', '=', claim.owner).where('id', '=', record.id).where('op_hash', '=', record.opHash).execute();
758
+ }
759
+ nextKnown = new Map(validRows);
760
+ for (const [hash] of sameSigner){
761
+ if (hash !== keepHash) nextKnown.delete(hash);
762
+ }
763
+ if (record.opHash === keepHash) nextKnown.set(record.opHash, {
764
+ copy: record,
765
+ op
766
+ });
767
+ await retainCredentialEntryCandidate(tx, record, claim, adapter);
768
+ return;
769
+ }
770
+ if (previous !== null && previous.address !== next?.address) {
771
+ await previous.remove(tx);
772
+ }
773
+ if (next !== null) {
774
+ await next.write(tx);
775
+ } else if (previous !== null) {
776
+ await removeByID(tx, type, claim.owner, record.id);
777
+ }
778
+ await retainCredentialEntryCandidate(tx, record, claim, adapter);
779
+ nextKnown = new Map([
780
+ [
781
+ record.opHash,
782
+ {
783
+ copy: record,
784
+ op
785
+ }
786
+ ]
787
+ ]);
788
+ });
789
+ if (nextKnown !== null) verifiedCopies.set(cacheKey, nextKnown);
790
+ }
791
+ async function decisionFromRows(rows, methods) {
792
+ const verified = [];
793
+ let firstError = null;
794
+ for (const row of rows){
795
+ let copy;
796
+ try {
797
+ copy = credentialRecordFromRow(row);
798
+ } catch (error) {
799
+ if (!(error instanceof CredentialRecordRowError)) throw error;
800
+ firstError ??= error;
801
+ continue;
802
+ }
803
+ const op = await verifySignedRecord(copy, methods);
804
+ if (op === null || !recordPayloadMatches(copy, op.claim)) continue;
805
+ verified.push({
806
+ copy,
807
+ op
808
+ });
809
+ }
810
+ const first = verified[0];
811
+ const decision = first === undefined ? null : recordDecisionFromVerifiedCopies({
812
+ type: first.copy.type,
813
+ owner: first.op.claim.owner,
814
+ id: first.copy.id
815
+ }, verified, 'hlc');
816
+ if (decision === null && firstError !== null) throw firstError;
817
+ return decision;
818
+ }
819
+ /**
820
+ * The records primitive's view of the credential store.
821
+ *
822
+ * `get` throws {@link CredentialRecordRowError} on a corrupt row rather than answering
823
+ * absent, so apply fails closed on it. `list` skips such a row, reporting it through
824
+ * `onCorruptRow`, so one bad row does not hide every other record of its type. Rows the
825
+ * legacy verbs wrote into the indexed tables are not records and never appear.
826
+ */ export function credentialRecordAdapter(store, options = {}) {
827
+ const adapter = {
828
+ isTransaction: store.isTransaction,
829
+ async transaction (fn) {
830
+ return await store.transaction(async (tx)=>await fn(credentialRecordAdapter(tx, options)));
831
+ },
832
+ async get (key) {
833
+ if (!isCredentialRecordType(key.type)) return null;
834
+ return await decisionFromRows(await store.getRecordRows(key), options.methods);
835
+ },
836
+ async getMany (keys) {
837
+ const rowsByKey = await store.getRecordRowsForKeys(keys.filter((key)=>isCredentialRecordType(key.type)));
838
+ const decisions = new Map();
839
+ for (const [text, rows] of rowsByKey){
840
+ const decision = await decisionFromRows(rows, options.methods);
841
+ if (decision !== null) decisions.set(text, decision);
842
+ }
843
+ return decisions;
844
+ },
845
+ async list (types) {
846
+ const decisions = [];
847
+ const groups = new Map();
848
+ for (const row of (await store.listRecordRows(types))){
849
+ if (!isCredentialRecordType(row.type)) continue;
850
+ const key = `${row.type}\u0000${row.owner}\u0000${row.id}`;
851
+ const rows = groups.get(key) ?? [];
852
+ rows.push(row);
853
+ groups.set(key, rows);
854
+ }
855
+ for (const rows of groups.values()){
856
+ try {
857
+ const decision = await decisionFromRows(rows, options.methods);
858
+ if (decision !== null) decisions.push(decision);
859
+ } catch (error) {
860
+ if (!(error instanceof CredentialRecordRowError)) throw error;
861
+ const row = rows[0];
862
+ options.onCorruptRow?.({
863
+ type: row.type,
864
+ id: row.id,
865
+ error
866
+ });
867
+ }
868
+ }
869
+ return decisions;
870
+ },
871
+ async putCopy (record) {
872
+ await store.putRecord(record, options.methods);
873
+ },
874
+ async pruneCopy (key, signerDID) {
875
+ if (!isCredentialRecordType(key.type)) return;
876
+ await store.transaction(async (tx)=>{
877
+ const decision = await decisionFromRows(await tx.getRecordRows(key), options.methods);
878
+ if (decision === null || decision.copies.length < 2) return;
879
+ for (const copy of decision.copies){
880
+ const op = await verifySignedRecord(copy, options.methods);
881
+ if (op !== null && normalizeDID(op.issuer) === normalizeDID(signerDID)) {
882
+ await tx.deleteRecordCopy(key, copy.opHash);
883
+ }
884
+ }
885
+ });
886
+ },
887
+ async retainCredentialEntryCandidate (record, methods) {
888
+ await store.retainEntryCandidate(record, methods);
889
+ },
890
+ async lock (keys) {
891
+ await store.lockKeyRows(keys.map((key)=>key.key));
892
+ }
893
+ };
894
+ adapterStores.set(adapter, store);
895
+ return adapter;
896
+ }
897
+ const adapterStores = new WeakMap();
898
+ /**
899
+ * The credential store behind an adapter {@link credentialRecordAdapter} made, bound to the
900
+ * same transaction, or `null` for any other adapter. A record type's hooks receive only the
901
+ * adapter; this lets them reach the indexed tables inside the transaction they run in,
902
+ * rather than through a store bound outside it.
903
+ */ export function credentialStoreOf(adapter) {
904
+ return adapterStores.get(adapter) ?? null;
905
+ }