@kubun/plugin-p2p 0.14.0 → 0.15.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.
Files changed (45) hide show
  1. package/lib/context/peer.js +250 -3
  2. package/lib/context/sync.js +134 -25
  3. package/lib/context/types.d.ts +7 -0
  4. package/lib/groups/broadcast-message.d.ts +29 -0
  5. package/lib/groups/broadcast.d.ts +11 -1
  6. package/lib/groups/broadcast.js +44 -2
  7. package/lib/groups/credential-apply.d.ts +64 -2
  8. package/lib/groups/credential-apply.js +215 -30
  9. package/lib/groups/credential-grant.d.ts +22 -0
  10. package/lib/groups/credential-grant.js +76 -2
  11. package/lib/groups/credential-manifest-token.d.ts +31 -0
  12. package/lib/groups/credential-manifest-token.js +49 -0
  13. package/lib/groups/credential-readiness.d.ts +69 -0
  14. package/lib/groups/credential-readiness.js +172 -0
  15. package/lib/groups/credential-wrapping-deps.d.ts +23 -0
  16. package/lib/groups/credential-wrapping-deps.js +25 -0
  17. package/lib/groups/grantor-authority.d.ts +65 -0
  18. package/lib/groups/grantor-authority.js +107 -0
  19. package/lib/groups/group-handlers.js +7 -0
  20. package/lib/groups/group-peer-manager.d.ts +25 -0
  21. package/lib/groups/group-peer-manager.js +71 -0
  22. package/lib/groups/group-protocols.d.ts +47 -0
  23. package/lib/groups/group-protocols.js +28 -0
  24. package/lib/hub/wiring.d.ts +24 -0
  25. package/lib/hub/wiring.js +17 -1
  26. package/lib/index.d.ts +14 -0
  27. package/lib/index.js +57 -1
  28. package/lib/protocol.d.ts +30 -0
  29. package/lib/protocol.js +36 -0
  30. package/lib/schema.js +78 -1
  31. package/lib/sync/handlers.js +21 -2
  32. package/lib/sync/held-delegations.d.ts +14 -0
  33. package/lib/sync/held-delegations.js +34 -0
  34. package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
  35. package/lib/sync/hub-tunnel-service-listener.js +289 -0
  36. package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
  37. package/lib/sync/hub-tunnel-service-provider.js +100 -0
  38. package/lib/sync/service-tunnel-listeners.d.ts +35 -0
  39. package/lib/sync/service-tunnel-listeners.js +165 -0
  40. package/lib/sync/sync-manager.d.ts +7 -0
  41. package/lib/sync/sync-manager.js +4 -1
  42. package/lib/sync/tunnel-topics.d.ts +19 -1
  43. package/lib/sync/tunnel-topics.js +7 -3
  44. package/lib/types.d.ts +172 -0
  45. package/package.json +48 -47
@@ -6,7 +6,7 @@ import { createReceiveAccessGate } from '../sync/receive-access-gate.js';
6
6
  import { applyAccessDefaultSetFrame } from './access-default-apply.js';
7
7
  import { verifyAccessDefault } from './access-default-token.js';
8
8
  import { verifyCatalog } from './catalog-token.js';
9
- import { applyCredentialKeyGrantFrame } from './credential-apply.js';
9
+ import { applyCredentialKeyGrantFrame, applyCredentialManifestFrame } from './credential-apply.js';
10
10
  import { storeReceivedGrant } from './store-received-grant.js';
11
11
  import { storeReceivedRevocation } from './store-received-revocation.js';
12
12
  /**
@@ -229,6 +229,12 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
229
229
  applied: false
230
230
  };
231
231
  }
232
+ // Both halves or neither -- a partial source is unwired, so the
233
+ // delegated wrapping fails closed.
234
+ const credentialDeps = params.credentialControllerResolver != null && params.credentialRevocationChecker != null ? {
235
+ controllerResolver: params.credentialControllerResolver,
236
+ revocationChecker: params.credentialRevocationChecker
237
+ } : undefined;
232
238
  const applied = await applyCredentialKeyGrantFrame({
233
239
  store: params.credentialStore,
234
240
  frame: message,
@@ -240,7 +246,43 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
240
246
  ...params.logger != null ? {
241
247
  logger: params.logger
242
248
  } : {},
243
- groupID
249
+ groupID,
250
+ ...credentialDeps != null ? {
251
+ deps: credentialDeps
252
+ } : {}
253
+ });
254
+ return {
255
+ applied
256
+ };
257
+ }
258
+ case 'credential:key-manifest':
259
+ {
260
+ if (params.credentialStore == null) {
261
+ params.logger?.warn('credential:key-manifest dropped: no credential store on this device', {
262
+ groupID
263
+ });
264
+ return {
265
+ applied: false
266
+ };
267
+ }
268
+ // Both halves or neither, as the grant case above -- a partial source
269
+ // is unwired, so the manifest fails closed.
270
+ const credentialDeps = params.credentialControllerResolver != null && params.credentialRevocationChecker != null ? {
271
+ controllerResolver: params.credentialControllerResolver,
272
+ revocationChecker: params.credentialRevocationChecker
273
+ } : undefined;
274
+ const applied = await applyCredentialManifestFrame({
275
+ store: params.credentialStore,
276
+ frame: message,
277
+ selfDID: params.selfDID,
278
+ maxDriftMS: params.maxDriftMS ?? DEFAULT_MAX_DRIFT_MS,
279
+ ...params.logger != null ? {
280
+ logger: params.logger
281
+ } : {},
282
+ groupID,
283
+ ...credentialDeps != null ? {
284
+ deps: credentialDeps
285
+ } : {}
244
286
  });
245
287
  return {
246
288
  applied
@@ -2,6 +2,7 @@ import { HLC } from '@kubun/hlc';
2
2
  import type { Logger } from '@kubun/logger';
3
3
  import type { CredentialStoreAPI } from '@kubun/store-credential';
4
4
  import type { CredentialKeyBundle, GroupBroadcastMessage } from './broadcast-message.js';
5
+ import { type DelegatedWrappingCheckDeps } from './grantor-authority.js';
5
6
  export type ApplyCredentialKeyGrantFrameParams = {
6
7
  store: CredentialStoreAPI;
7
8
  frame: Extract<GroupBroadcastMessage, {
@@ -24,6 +25,11 @@ export type ApplyCredentialKeyGrantFrameParams = {
24
25
  maxDriftMS: number;
25
26
  logger?: Logger;
26
27
  groupID?: string;
28
+ /**
29
+ * Present-time admission gate for a delegate-signed wrapping, forwarded to
30
+ * {@link applyCredentialKeyRows}. Unwired means the wrapping refuses (fail-closed).
31
+ */
32
+ deps?: DelegatedWrappingCheckDeps;
27
33
  };
28
34
  /**
29
35
  * Apply a signed `credential:key-grant`: the key's public record, the one
@@ -42,6 +48,42 @@ export type ApplyCredentialKeyGrantFrameParams = {
42
48
  * Returns whether anything was written.
43
49
  */
44
50
  export declare function applyCredentialKeyGrantFrame(params: ApplyCredentialKeyGrantFrameParams): Promise<boolean>;
51
+ export type ApplyCredentialManifestFrameParams = {
52
+ store: CredentialStoreAPI;
53
+ frame: Extract<GroupBroadcastMessage, {
54
+ type: 'credential:key-manifest';
55
+ }>;
56
+ /**
57
+ * This device, for the recipient check: a manifest fans out group-wide but
58
+ * names ONE recipient in its signed claim.
59
+ */
60
+ selfDID: string;
61
+ /**
62
+ * Future-drift bound applied to the manifest's `issuedAt`. `issuedAt` is a
63
+ * drift bound ONLY -- ordering/authorization is `sequence`.
64
+ */
65
+ maxDriftMS: number;
66
+ logger?: Logger;
67
+ groupID?: string;
68
+ /**
69
+ * The administer-chain check's deps. Unwired means the gate refuses
70
+ * (fail-closed) -- a manifest is stored only when its chain check passes.
71
+ */
72
+ deps?: DelegatedWrappingCheckDeps;
73
+ };
74
+ /**
75
+ * Apply a `credential:key-manifest`: verify the token, require the grantor's
76
+ * present-time `credential/administer` chain over the owner, require
77
+ * `recipientDID` to name this device, bound `issuedAt` against future drift,
78
+ * then store under the lexicographic `(epoch, sequence)` rule.
79
+ *
80
+ * ADVISORY only -- a recipient proves readiness by decrypt-verifying its own
81
+ * keys, never by trusting this record. Fail-CLOSED: any check failure drops
82
+ * without storing.
83
+ *
84
+ * Returns whether a row was stored.
85
+ */
86
+ export declare function applyCredentialManifestFrame(params: ApplyCredentialManifestFrameParams): Promise<boolean>;
45
87
  /** A revoked subject's tombstone as the reconcile lane ships it. */
46
88
  export type CredentialReconcileTombstone = {
47
89
  keyID: string;
@@ -59,20 +101,40 @@ export type ApplyCredentialReconcileParams = {
59
101
  bundles: Array<CredentialKeyBundle>;
60
102
  /** Tombstones for advertised held IDs the server reports revoked. */
61
103
  tombstones: Array<CredentialReconcileTombstone>;
104
+ /**
105
+ * Manifests riding with this catch-up response, applied via
106
+ * {@link applyCredentialManifestFrame} BEFORE the bundles below -- the
107
+ * fail-safe order: a crash mid-apply leaves the expectation recorded with
108
+ * keys still to follow, never keys landed with nothing that predicted them.
109
+ */
110
+ manifests?: Array<{
111
+ manifest: string;
112
+ delegationTokens: Array<string>;
113
+ }>;
62
114
  hlc?: HLC;
63
115
  /** Future-drift bound on the granter's stamps. Defaults to {@link DEFAULT_MAX_DRIFT_MS}. */
64
116
  maxDriftMS?: number;
65
117
  logger?: Logger;
118
+ /** Forwarded to {@link applyCredentialKeyRows} and {@link applyCredentialManifestFrame}. */
119
+ deps?: DelegatedWrappingCheckDeps;
66
120
  };
67
121
  export type ApplyCredentialReconcileResult = {
122
+ /** Manifests that wrote a row. */
123
+ manifestsApplied: number;
68
124
  /** Bundles that wrote rows. */
69
125
  applied: number;
70
126
  /** Tombstones that deleted a row. */
71
127
  tombstoned: number;
72
128
  };
73
129
  /**
74
- * Apply a credential reconcile response: materialise the catch-up bundles a
75
- * recipient missed, then GC the rows a tombstone revokes.
130
+ * Apply a credential reconcile response: store the manifests it carries,
131
+ * materialise the catch-up bundles a recipient missed, then GC the rows a
132
+ * tombstone revokes — manifests first, so readiness never transiently
133
+ * under-declares.
134
+ *
135
+ * Runs as ONE atomic unit via `store.transaction`, which is nesting-safe
136
+ * (mirrors `writeAtomically`), so this never throws a Kysely nested-tx error
137
+ * even when `store` already runs inside an ambient transaction.
76
138
  *
77
139
  * The server's scoping is NOT trusted. Every row re-verifies its op signature
78
140
  * and every wrapping re-checks it is addressed to this device before a write,
@@ -4,6 +4,8 @@ import { HLC } from '@kubun/hlc';
4
4
  import { DEFAULT_MAX_DRIFT_MS } from '@kubun/mutation';
5
5
  import { fromB64U } from '@sozai/codec';
6
6
  import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappingDigest, verifyCredentialKeyGrant } from './credential-grant-token.js';
7
+ import { verifyCredentialManifest } from './credential-manifest-token.js';
8
+ import { authorizeDelegatedWrapping, checkAdministerChain, verifyGrantorAuthority } from './grantor-authority.js';
7
9
  /**
8
10
  * Apply a signed `credential:key-grant`: the key's public record, the one
9
11
  * wrapping addressed to this device, and every entry ciphertext at that version.
@@ -51,7 +53,10 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
51
53
  groupID,
52
54
  bundle: frame,
53
55
  requireIssuer: verified.issuer,
54
- label: 'credential:key-grant'
56
+ label: 'credential:key-grant',
57
+ ...params.deps != null ? {
58
+ deps: params.deps
59
+ } : {}
55
60
  });
56
61
  }
57
62
  /**
@@ -201,6 +206,72 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
201
206
  branchID: op.claim.branchID ?? ''
202
207
  };
203
208
  };
209
+ /**
210
+ * As {@link acceptOp}, but for the WRAPPING subject also accepts an op
211
+ * signed by a delegate `G` the owner has authorized (entry/key-branch ops
212
+ * stay on `acceptOp`'s owner-only rule). An op byte-identical to what this
213
+ * device already holds is accepted as trusted local state with no
214
+ * authorization check; otherwise `bundle.grantorAuthority` must be present
215
+ * and `authorizeDelegatedWrapping` must return `true`.
216
+ */ const acceptWrappingOp = async (token, subject)=>{
217
+ const verified = await verifyCredentialOp(token);
218
+ if (verified == null || !credentialOpMatchesPut(verified.claim, subject)) {
219
+ return null;
220
+ }
221
+ const opInput = {
222
+ keyID: verified.claim.keyID,
223
+ authorDID: verified.issuer,
224
+ hlc: verified.claim.hlc,
225
+ opJWT: token,
226
+ opHash: digestParts([
227
+ token
228
+ ]),
229
+ branchID: verified.claim.branchID ?? ''
230
+ };
231
+ if (verified.issuer === issuer) {
232
+ return {
233
+ op: opInput
234
+ };
235
+ }
236
+ // Hash computed the same way the store persists `op_hash` (mirrors
237
+ // `acceptOp`) -- a mismatch would fail-open the held short-circuit below
238
+ // in either direction.
239
+ const incomingHash = opInput.opHash;
240
+ const stored = await store.getOp('wrapping', bundle.wrapping.wrappingID);
241
+ if (stored?.op_hash === incomingHash) {
242
+ // Already-admitted op: trusted local state, no re-authorization or
243
+ // envelope persist needed.
244
+ return {
245
+ op: opInput
246
+ };
247
+ }
248
+ if (bundle.grantorAuthority == null) {
249
+ return null;
250
+ }
251
+ const authorized = await authorizeDelegatedWrapping({
252
+ envelope: bundle.grantorAuthority,
253
+ wrappingOpIssuer: verified.issuer,
254
+ wrappingOpHash: incomingHash,
255
+ ownerDID: issuer,
256
+ deps: params.deps ?? {}
257
+ });
258
+ if (!authorized) {
259
+ return null;
260
+ }
261
+ // Re-verified only to recover `refreshSeq` for the provenance write below
262
+ // -- `authorized === true` already proved the binding.
263
+ const claim = await verifyGrantorAuthority(bundle.grantorAuthority);
264
+ if (claim == null) {
265
+ return null;
266
+ }
267
+ return {
268
+ op: opInput,
269
+ authority: {
270
+ envelope: bundle.grantorAuthority,
271
+ refreshSeq: claim.refreshSeq
272
+ }
273
+ };
274
+ };
204
275
  const keyOpHash = digestParts([
205
276
  bundle.keyOp
206
277
  ]);
@@ -242,7 +313,7 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
242
313
  branchID: `${verified.claim.hlc}:${opHash}`
243
314
  });
244
315
  }
245
- const wrappingOp = await acceptOp(bundle.wrapping.op, {
316
+ const acceptedWrapping = await acceptWrappingOp(bundle.wrapping.op, {
246
317
  kind: 'wrapping',
247
318
  params: wrappingParams
248
319
  });
@@ -250,13 +321,14 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
250
321
  kind: 'entry',
251
322
  params: put.params
252
323
  })));
253
- if (wrappingOp == null || entryOps.some((op)=>op == null)) {
324
+ if (acceptedWrapping == null || entryOps.some((op)=>op == null)) {
254
325
  logger?.warn(`${label} dropped: an op does not speak for the row it travels with`, {
255
326
  groupID,
256
327
  keyID: bundle.keyID
257
328
  });
258
329
  return false;
259
330
  }
331
+ const wrappingOp = acceptedWrapping.op;
260
332
  const provenBranchIDs = new Set(keyBranches.map((branch)=>branch.branchID));
261
333
  for (const rowOp of [
262
334
  wrappingOp,
@@ -302,7 +374,10 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
302
374
  branchID: branch.branchID
303
375
  });
304
376
  }
305
- await store.putWrapping(wrappingParams, wrappingOp);
377
+ // Envelope, when present, writes in the SAME transaction as the wrapping
378
+ // row -- a crash between the two must never leave a wrapping with no
379
+ // provenance record.
380
+ await store.putWrapping(wrappingParams, wrappingOp, acceptedWrapping.authority);
306
381
  for (const [index, put] of entryPuts.entries()){
307
382
  await store.putEntry(put.params, entryOps[index]);
308
383
  // Merged so a later local write sorts after what was received. Safe to
@@ -316,41 +391,151 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
316
391
  return true;
317
392
  }
318
393
  /**
319
- * Apply a credential reconcile response: materialise the catch-up bundles a
320
- * recipient missed, then GC the rows a tombstone revokes.
394
+ * Apply a `credential:key-manifest`: verify the token, require the grantor's
395
+ * present-time `credential/administer` chain over the owner, require
396
+ * `recipientDID` to name this device, bound `issuedAt` against future drift,
397
+ * then store under the lexicographic `(epoch, sequence)` rule.
398
+ *
399
+ * ADVISORY only -- a recipient proves readiness by decrypt-verifying its own
400
+ * keys, never by trusting this record. Fail-CLOSED: any check failure drops
401
+ * without storing.
402
+ *
403
+ * Returns whether a row was stored.
404
+ */ export async function applyCredentialManifestFrame(params) {
405
+ const { store, frame, groupID, logger } = params;
406
+ const claim = await verifyCredentialManifest(frame.manifest);
407
+ if (claim == null) {
408
+ logger?.warn('credential:key-manifest dropped: token verification failed', {
409
+ groupID
410
+ });
411
+ return false;
412
+ }
413
+ // Ordinary outcome for every co-member but one, so debug not warning
414
+ // (mirrors `applyCredentialKeyRows`'s wrapping-recipient check).
415
+ if (normalizeDID(claim.recipientDID) !== normalizeDID(params.selfDID)) {
416
+ logger?.debug('credential:key-manifest ignored: manifest is addressed elsewhere', {
417
+ groupID,
418
+ ownerDID: claim.ownerDID
419
+ });
420
+ return false;
421
+ }
422
+ // Present-time admission: grantor must hold a live `credential/administer`
423
+ // chain over the owner, proven by `delegationTokens`. Fail-closed on
424
+ // unwired deps -- the SAME gate a delegated wrapping passes.
425
+ //
426
+ // Self-issued exception (grantor === owner), mirroring
427
+ // `computeCredentialProvisioningStatus`: `checkAdministerChain`'s synthetic
428
+ // top-level check doesn't apply to a self-grant. Safe because `grantorDID`
429
+ // comes only from the VERIFIED `iss`, never a payload field, so forging
430
+ // `grantorDID === ownerDID` requires already controlling the owner's
431
+ // signing key.
432
+ const authorized = normalizeDID(claim.grantorDID) === normalizeDID(claim.ownerDID) ? true : await checkAdministerChain({
433
+ grantorDID: claim.grantorDID,
434
+ ownerDID: claim.ownerDID,
435
+ delegationTokens: frame.delegationTokens,
436
+ deps: params.deps ?? {}
437
+ });
438
+ if (!authorized) {
439
+ logger?.warn('credential:key-manifest dropped: grantor holds no administer chain over the owner', {
440
+ groupID,
441
+ ownerDID: claim.ownerDID
442
+ });
443
+ return false;
444
+ }
445
+ // Future-drift bound only, unlike the grant lane's entry stamps: a manifest
446
+ // leaves no op row for the sync lane to order, so this never floors the
447
+ // device clock.
448
+ const stamp = HLC.tryParse(claim.issuedAt);
449
+ if (stamp == null || stamp.wallTime - Date.now() > params.maxDriftMS) {
450
+ logger?.warn('credential:key-manifest dropped: issuedAt stamp is unusable', {
451
+ groupID,
452
+ issuedAt: claim.issuedAt
453
+ });
454
+ return false;
455
+ }
456
+ // Replay check mirrors the store's own upsert rule (so `applied` reports
457
+ // accurately): lexicographic (epoch, sequence) -- a higher epoch always
458
+ // wins; within the same epoch, lower-or-equal sequence is replay.
459
+ const existing = await store.listManifests({
460
+ ownerDID: claim.ownerDID,
461
+ recipientDID: claim.recipientDID
462
+ });
463
+ const prior = existing.find((manifest)=>manifest.record.grantorDID === claim.grantorDID);
464
+ if (prior != null && (prior.record.epoch > claim.epoch || prior.record.epoch === claim.epoch && claim.sequence <= prior.record.sequence)) {
465
+ logger?.warn('credential:key-manifest dropped: replayed or stale epoch/sequence', {
466
+ groupID,
467
+ epoch: claim.epoch,
468
+ sequence: claim.sequence
469
+ });
470
+ return false;
471
+ }
472
+ await store.putManifest(claim, frame.manifest, frame.delegationTokens);
473
+ return true;
474
+ }
475
+ /**
476
+ * Apply a credential reconcile response: store the manifests it carries,
477
+ * materialise the catch-up bundles a recipient missed, then GC the rows a
478
+ * tombstone revokes — manifests first, so readiness never transiently
479
+ * under-declares.
480
+ *
481
+ * Runs as ONE atomic unit via `store.transaction`, which is nesting-safe
482
+ * (mirrors `writeAtomically`), so this never throws a Kysely nested-tx error
483
+ * even when `store` already runs inside an ambient transaction.
321
484
  *
322
485
  * The server's scoping is NOT trusted. Every row re-verifies its op signature
323
486
  * and every wrapping re-checks it is addressed to this device before a write,
324
487
  * exactly as an unsolicited grant would — a reconcile is a batch of the same
325
488
  * self-authenticating rows, pulled instead of pushed.
326
489
  */ export async function applyCredentialReconcile(params) {
327
- const { store, selfDID, logger } = params;
490
+ const { selfDID, logger } = params;
328
491
  const maxDriftMS = params.maxDriftMS ?? DEFAULT_MAX_DRIFT_MS;
329
- let applied = 0;
330
- for (const bundle of params.bundles){
331
- const wrote = await applyCredentialKeyRows({
332
- store,
333
- selfDID,
334
- hlc: params.hlc,
335
- maxDriftMS,
336
- logger,
337
- bundle,
338
- label: 'credential:reconcile'
339
- });
340
- if (wrote) {
341
- applied += 1;
492
+ return await params.store.transaction(async (store)=>{
493
+ let manifestsApplied = 0;
494
+ for (const { manifest, delegationTokens } of params.manifests ?? []){
495
+ const wrote = await applyCredentialManifestFrame({
496
+ store,
497
+ selfDID,
498
+ frame: {
499
+ type: 'credential:key-manifest',
500
+ manifest,
501
+ delegationTokens
502
+ },
503
+ maxDriftMS,
504
+ logger,
505
+ deps: params.deps
506
+ });
507
+ if (wrote) {
508
+ manifestsApplied += 1;
509
+ }
342
510
  }
343
- }
344
- let tombstoned = 0;
345
- for (const tombstone of params.tombstones){
346
- if (await applyReconcileTombstone(store, tombstone, logger)) {
347
- tombstoned += 1;
511
+ let applied = 0;
512
+ for (const bundle of params.bundles){
513
+ const wrote = await applyCredentialKeyRows({
514
+ store,
515
+ selfDID,
516
+ hlc: params.hlc,
517
+ maxDriftMS,
518
+ logger,
519
+ bundle,
520
+ label: 'credential:reconcile',
521
+ deps: params.deps
522
+ });
523
+ if (wrote) {
524
+ applied += 1;
525
+ }
348
526
  }
349
- }
350
- return {
351
- applied,
352
- tombstoned
353
- };
527
+ let tombstoned = 0;
528
+ for (const tombstone of params.tombstones){
529
+ if (await applyReconcileTombstone(store, tombstone, logger)) {
530
+ tombstoned += 1;
531
+ }
532
+ }
533
+ return {
534
+ manifestsApplied,
535
+ applied,
536
+ tombstoned
537
+ };
538
+ });
354
539
  }
355
540
  /**
356
541
  * Delete one revoked subject, keying on the tombstone op it carries.
@@ -62,7 +62,28 @@ export type GrantCredentialKeyParams = {
62
62
  * will resolve a request authority is a follow-up.
63
63
  */
64
64
  authority?: CredentialAuthority;
65
+ /**
66
+ * The credential key's owner and signing device `G`. Present and DISTINCT
67
+ * iff delegate-signed (absent, or equal, for an owner-signed grant, which
68
+ * needs no envelope). `identity` must resolve to `grantorDID` when
69
+ * delegate-signed -- `signGrantorAuthority` signs with `identity`, not
70
+ * this field.
71
+ */
72
+ ownerDID?: string;
73
+ grantorDID?: string;
74
+ /**
75
+ * `G`'s current administer capability chain, carried in the envelope for
76
+ * present-time admission checks. Ignored outside the delegate-signed path.
77
+ */
78
+ delegationTokens?: Array<string>;
65
79
  };
80
+ /**
81
+ * What a grant call settled as, beyond `created`. `grantCredentialKeyToMember`
82
+ * only ever produces `'granted'` or `'refreshed'` -- `'not-held'`/`'error'`
83
+ * are reserved for callers, kept here so nothing downstream needs a type
84
+ * this module doesn't produce.
85
+ */
86
+ export type CredentialGrantOutcomeKind = 'granted' | 'refreshed' | 'not-held' | 'error';
66
87
  export type GrantCredentialKeyResult = {
67
88
  version: number;
68
89
  /**
@@ -70,6 +91,7 @@ export type GrantCredentialKeyResult = {
70
91
  * way: a repeat grant is how an undelivered one is retried.
71
92
  */
72
93
  created: boolean;
94
+ outcome: CredentialGrantOutcomeKind;
73
95
  };
74
96
  /**
75
97
  * Grant a credential key to a co-member, resolving what to wrap to from the MLS
@@ -1,6 +1,7 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
2
  import { toB64U } from '@sozai/codec';
3
3
  import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappingDigest, signCredentialKeyGrant } from './credential-grant-token.js';
4
+ import { signGrantorAuthority } from './grantor-authority.js';
4
5
  /**
5
6
  * The recipient of a grant holds no leaf in the group, so there is no
6
7
  * authenticated document to wrap to.
@@ -69,13 +70,82 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
69
70
  const result = await credentials.grant(keyID, wrappableDID, {
70
71
  authority: params.authority
71
72
  });
73
+ const outcome = await mintGrantorAuthorityIfDelegated({
74
+ store,
75
+ identity,
76
+ keyID,
77
+ wrappableDID,
78
+ result,
79
+ grant: params
80
+ });
72
81
  // Read back NOW and dispatch on commit, not the reverse. The read has to run
73
82
  // on the caller's connection while the transaction is still open, and it has
74
83
  // to see the state the grant left rather than whatever a later write leaves —
75
84
  // an entry written after this point belongs to its own `credential:entry-put`.
85
+ // Also reads back the envelope `mintGrantorAuthorityIfDelegated` just
86
+ // persisted (or none), via `readCredentialKeyBundle`'s `getWrappingAuthority`
87
+ // call — no second write path.
76
88
  const frame = await readGrantFrame(store, identity, keyID, wrappableDID);
77
89
  stores.onCommit(()=>params.scheduleBroadcast(groupID, frame));
78
- return result;
90
+ return {
91
+ version: result.version,
92
+ created: result.created,
93
+ outcome
94
+ };
95
+ }
96
+ /**
97
+ * Mint or refresh the beside-the-op grantor-authority envelope for a
98
+ * delegate-signed grant, and report the outcome.
99
+ *
100
+ * Gated on `ownerDID`/`grantorDID` present AND distinct -- an owner-signed
101
+ * grant needs no envelope (owner authority doesn't expire like a delegate's
102
+ * chain).
103
+ *
104
+ * Runs on BOTH first grant and re-grant: nothing else in this codebase mints
105
+ * a grantor-authority envelope, so a delegate-signed first grant must mint
106
+ * its envelope here too, or ship with none.
107
+ */ async function mintGrantorAuthorityIfDelegated(params) {
108
+ const { store, identity, keyID, wrappableDID, result, grant } = params;
109
+ const { ownerDID, grantorDID } = grant;
110
+ if (ownerDID == null || grantorDID == null || normalizeDID(grantorDID) === normalizeDID(ownerDID)) {
111
+ return 'granted';
112
+ }
113
+ const wrappings = await store.listWrappings(keyID, result.version);
114
+ const wrapping = findWrappingForRecipient(wrappings, wrappableDID);
115
+ if (wrapping == null) {
116
+ throw new CredentialGrantNotReadable(keyID, `no wrapping for ${normalizeDID(wrappableDID)} at v${result.version}`);
117
+ }
118
+ // The SAME `op_hash` the store holds -- never recomputed, since this
119
+ // function has no token to recompute it from.
120
+ const op = await store.getOp('wrapping', wrapping.wrapping_id);
121
+ if (op == null || op.deleted !== 0) {
122
+ throw new CredentialGrantNotReadable(keyID, `no live op for wrapping ${wrapping.wrapping_id}`);
123
+ }
124
+ // The wrapping's own (un-normalized) recipient -- what a receiver compares
125
+ // `bundle.wrapping.recipientDID` against.
126
+ const recipientDID = wrapping.recipient_did ?? wrappableDID;
127
+ const refreshSeq = await store.nextGrantorSequence({
128
+ ownerDID,
129
+ recipientDID,
130
+ grantorDID
131
+ });
132
+ const envelope = await signGrantorAuthority(identity, {
133
+ wrappingOpHash: op.op_hash,
134
+ refreshSeq,
135
+ ownerDID,
136
+ recipientDID,
137
+ delegationTokens: grant.delegationTokens ?? []
138
+ });
139
+ await store.putWrappingAuthority({
140
+ wrappingID: wrapping.wrapping_id,
141
+ envelope,
142
+ refreshSeq
143
+ });
144
+ return result.created ? 'granted' : 'refreshed';
145
+ }
146
+ /** The wrapping addressed to `recipientWrappableDID`, or `undefined` when none. */ function findWrappingForRecipient(wrappings, recipientWrappableDID) {
147
+ const recipient = normalizeDID(recipientWrappableDID);
148
+ return wrappings.find((candidate)=>candidate.recipient_did != null && normalizeDID(candidate.recipient_did) === recipient);
79
149
  }
80
150
  /**
81
151
  * Rebuild the frame from what the grant persisted, rather than from what the
@@ -134,6 +204,7 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
134
204
  throw new CredentialGrantNotReadable(keyID, `no wrapping for ${recipient} at v${key.version}`);
135
205
  }
136
206
  const entries = await store.listEntries(keyID);
207
+ const authority = await store.getWrappingAuthority(wrapping.wrapping_id);
137
208
  const carriedWrapping = {
138
209
  wrappingID: wrapping.wrapping_id,
139
210
  factors: wrapping.factors,
@@ -160,6 +231,9 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
160
231
  keyOp: await readOp('key', keyID),
161
232
  keyBranches: (await store.listKeyBranches(keyID, key.version)).map((branch)=>branch.op_jwt),
162
233
  wrapping: carriedWrapping,
163
- entries: carriedEntries
234
+ entries: carriedEntries,
235
+ ...authority != null ? {
236
+ grantorAuthority: authority.envelope
237
+ } : {}
164
238
  };
165
239
  }
@@ -0,0 +1,31 @@
1
+ import { type SigningIdentity } from '@kokuin/token';
2
+ /**
3
+ * What a grantor signs to record the set of keys it granted a recipient: a
4
+ * claim the recipient checks its store against to prove completeness. Domain
5
+ * separation from other signed payloads comes from the `kind` marker plus the
6
+ * structural check in `verifyCredentialManifest` -- `signToken` has no
7
+ * domain override of its own.
8
+ */
9
+ export type CredentialManifestClaim = {
10
+ kind: 'credential/key-manifest';
11
+ ownerDID: string;
12
+ recipientDID: string;
13
+ grantorDID: string;
14
+ epoch: number;
15
+ sequence: number;
16
+ keys: Array<{
17
+ keyID: string;
18
+ version: number;
19
+ }>;
20
+ issuedAt: string;
21
+ };
22
+ /** The envelope carried on the wire and persisted is the signed token string itself. */
23
+ export type CredentialManifestEnvelope = string;
24
+ export declare function signCredentialManifest(identity: SigningIdentity, claim: Omit<CredentialManifestClaim, 'kind' | 'grantorDID'>): Promise<CredentialManifestEnvelope>;
25
+ /**
26
+ * Verify a credential-manifest envelope and extract its claim. Returns `null`
27
+ * (never throws) when the token is unparseable, unsigned (`alg: 'none'`), or
28
+ * structurally not a credential-manifest claim -- `grantorDID` is trusted
29
+ * only from the verified `iss`, never from a same-named field in the payload.
30
+ */
31
+ export declare function verifyCredentialManifest(envelope: CredentialManifestEnvelope): Promise<CredentialManifestClaim | null>;