@kubun/plugin-p2p 0.12.0 → 0.13.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 (62) hide show
  1. package/lib/context/group.js +3 -0
  2. package/lib/context/join.js +3 -0
  3. package/lib/context/peer.js +7 -2
  4. package/lib/context/sync.js +63 -5
  5. package/lib/context/types.d.ts +14 -1
  6. package/lib/context/types.js +12 -0
  7. package/lib/groups/access-default-apply.d.ts +42 -0
  8. package/lib/groups/access-default-apply.js +75 -0
  9. package/lib/groups/broadcast-codec.d.ts +1 -1
  10. package/lib/groups/broadcast-message.d.ts +240 -0
  11. package/lib/groups/broadcast-message.js +1 -0
  12. package/lib/groups/broadcast.d.ts +19 -266
  13. package/lib/groups/broadcast.js +30 -204
  14. package/lib/groups/credential-apply.d.ts +82 -0
  15. package/lib/groups/credential-apply.js +410 -0
  16. package/lib/groups/credential-grant-token.d.ts +42 -0
  17. package/lib/groups/credential-grant-token.js +51 -0
  18. package/lib/groups/credential-grant.d.ts +98 -0
  19. package/lib/groups/credential-grant.js +165 -0
  20. package/lib/groups/group-handlers.js +25 -2
  21. package/lib/groups/group-mls.d.ts +5 -0
  22. package/lib/groups/group-mls.js +4 -1
  23. package/lib/groups/group-peer-manager.d.ts +7 -1
  24. package/lib/groups/group-peer-manager.js +44 -1
  25. package/lib/groups/group-protocols.d.ts +227 -0
  26. package/lib/groups/group-protocols.js +146 -0
  27. package/lib/groups/join-utils.d.ts +5 -0
  28. package/lib/groups/join-utils.js +5 -1
  29. package/lib/groups/ledger-adopt.d.ts +84 -0
  30. package/lib/groups/ledger-adopt.js +142 -0
  31. package/lib/groups/ledger-commit-fold.d.ts +3 -1
  32. package/lib/groups/ledger-commit-fold.js +3 -0
  33. package/lib/groups/ledger-ingest.d.ts +23 -1
  34. package/lib/groups/ledger-ingest.js +30 -1
  35. package/lib/groups/manager.d.ts +5 -0
  36. package/lib/groups/manager.js +6 -1
  37. package/lib/groups/peer-presence.d.ts +9 -2
  38. package/lib/groups/peer-presence.js +14 -2
  39. package/lib/groups/peer-selection.d.ts +9 -0
  40. package/lib/groups/peer-selection.js +10 -0
  41. package/lib/hub/wiring.d.ts +6 -1
  42. package/lib/hub/wiring.js +2 -1
  43. package/lib/index.d.ts +5 -1
  44. package/lib/index.js +58 -6
  45. package/lib/peer/blob-fetch.d.ts +45 -0
  46. package/lib/peer/blob-fetch.js +89 -0
  47. package/lib/peer/blob-handlers.d.ts +11 -0
  48. package/lib/peer/blob-handlers.js +123 -0
  49. package/lib/peer/controller-fetch.d.ts +6 -0
  50. package/lib/peer/controller-fetch.js +59 -0
  51. package/lib/peer/controller-handlers.d.ts +10 -0
  52. package/lib/peer/controller-handlers.js +28 -0
  53. package/lib/protocol.d.ts +279 -0
  54. package/lib/protocol.js +358 -0
  55. package/lib/sync/access-default-sender.d.ts +1 -1
  56. package/lib/sync/broadcast-queue.d.ts +1 -1
  57. package/lib/sync/forwarder.d.ts +1 -1
  58. package/lib/sync/handlers.js +98 -1
  59. package/lib/sync/sync-manager.d.ts +10 -1
  60. package/lib/sync/sync-manager.js +24 -2
  61. package/lib/types.d.ts +18 -0
  62. package/package.json +55 -43
@@ -1,159 +1,18 @@
1
1
  import type { OwnIdentity } from '@kokuin/token';
2
2
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
3
- import { HLC } from '@kubun/hlc';
3
+ import type { HLC } from '@kubun/hlc';
4
4
  import type { Logger } from '@kubun/logger';
5
- import type { CatalogRecord } from '@kubun/protocol';
5
+ import type { CredentialStoreAPI } from '@kubun/store-credential';
6
6
  import type { DelegationStoreAPI } from '@kubun/store-delegation';
7
7
  import type { GraphStoreAPI } from '@kubun/store-graph';
8
8
  import type { P2PStoreAPI } from '@kubun/store-p2p';
9
- import type { GroupAnchor, LedgerLogEntry, RosterState } from '@kumiai/mls';
9
+ import type { GroupAnchor, LedgerLogEntry } from '@kumiai/mls';
10
10
  import { type ForwardingConfig } from '../sync/forwarder.js';
11
11
  import type { StoreUnreadableMode } from '../types.js';
12
- import { type AccessDefaultRule } from './access-default-token.js';
12
+ import type { GroupBroadcastMessage } from './broadcast-message.js';
13
13
  import type { P2PEventEmitter } from './events.js';
14
- import type { PeerAvailability, PeerCapability } from './group-protocols.js';
15
- import { type AffectedRow, type LedgerIngestParams } from './ledger-ingest.js';
16
- export type MutationApplyEntry = {
17
- mutationJWT: string;
18
- docID: string;
19
- /** HLC string identifying this mutation's version. */
20
- version: string;
21
- modelID: string;
22
- };
23
- /**
24
- * Optional signed token over a control operation. Its issuer (`iss`) is the
25
- * authenticated author; receivers verify it and authorize that author as a
26
- * group admin, dropping the operation when the token is absent or invalid. The
27
- * signed payload is the whole message minus this field, so the signature covers
28
- * every operation field, not just the author.
29
- */
30
- type ControlAuth = string;
31
- export type GroupBroadcastMessage = {
32
- type: 'catalog:create';
33
- catalog: CatalogRecord;
34
- auth?: ControlAuth;
35
- } | {
36
- type: 'catalog:update';
37
- catalogID: string;
38
- update: {
39
- name?: string;
40
- description?: string;
41
- filterCriteria?: CatalogRecord['filterCriteria'];
42
- hlc: string;
43
- };
44
- auth?: ControlAuth;
45
- } | {
46
- type: 'catalog:delete';
47
- catalogID: string;
48
- hlc: string;
49
- auth?: ControlAuth;
50
- } | {
51
- /**
52
- * A member states its own model access-default so co-members can evaluate
53
- * the owner's documents. `auth` is a signed token whose issuer is the
54
- * owner; the receiver derives every authoritative field from that verified
55
- * token and applies the rule ONLY when `ownerDID` equals the issuer (a
56
- * member states its own policy, never another's). LWW by `hlc`.
57
- */
58
- type: 'access-default:set';
59
- modelID: string;
60
- permissionType: 'read' | 'write';
61
- rule: {
62
- level: string;
63
- allowedDIDs: Array<string> | null;
64
- allowedCircles: Array<string> | null;
65
- allowedGroups: Array<string> | null;
66
- };
67
- ownerDID: string;
68
- hlc: string;
69
- auth: ControlAuth;
70
- } | {
71
- /**
72
- * A member removes its own model access-default(s). Same sender-bound and
73
- * LWW rules as `access-default:set`: applied only when `ownerDID` equals
74
- * the verified `auth` issuer and the removal `hlc` is newer than the
75
- * stored rule's.
76
- */
77
- type: 'access-default:remove';
78
- modelID: string;
79
- permissionTypes: Array<'read' | 'write'>;
80
- ownerDID: string;
81
- hlc: string;
82
- auth: ControlAuth;
83
- } | {
84
- /**
85
- * Distributes a minted `document/write` capability token to group
86
- * co-members. The recipient verifies the token's signature and stores a
87
- * held row only when the token's audience (`aud`) is the receiving
88
- * device, so the engine's auto-attach can reuse it on later mutations.
89
- */
90
- type: 'delegation:share';
91
- /** Stringified capability JWT carrying the `document/write` grant. */
92
- token: string;
93
- /** Group whose broadcast channel carries this grant. */
94
- groupID: string;
95
- /** HLC stamped by the grantor; mirrors the grantor-side issued row. */
96
- hlc: string;
97
- } | {
98
- /**
99
- * Distributes a signed revocation record for a previously minted
100
- * `document/write` capability. The recipient verifies the token's
101
- * signature and stores the revocation; verification against the
102
- * referenced cap's `iss` flips `verified_at` once the cap is known.
103
- * Revocation is binary and reactive — once verified locally, any
104
- * subsequent mutation under the revoked `jti` is denied regardless
105
- * of `mutation.atTime`.
106
- */
107
- type: 'delegation:revoke';
108
- /** Stringified signed revocation JWT carrying `{ jti, iss, rev, iat }`. */
109
- token: string;
110
- /** Group whose broadcast channel carries this revocation. */
111
- groupID: string;
112
- /** HLC stamped by the revoker; mirrors the revoker-side stored row. */
113
- hlc: string;
114
- } | {
115
- /**
116
- * A member announces a voluntary leave. MLS forbids self-removal, so this
117
- * is an advisory notification (not an authoritative removal): receivers
118
- * surface a `groupLeaveRequested` event for an admin to act on by issuing
119
- * a real `removeMember`. Deliberately does NOT tombstone — a forged
120
- * leave-request must not be able to evict an arbitrary member.
121
- */
122
- type: 'group:leaveRequest';
123
- groupID: string;
124
- memberDID: string;
125
- hlc: string;
126
- } | {
127
- type: 'mutation:apply';
128
- entries: Array<MutationApplyEntry>;
129
- /**
130
- * DID of the peer this broadcast arrived FROM — used for loopback drop.
131
- * For a forwarded mutation that is the forwarding peer, not the author, so
132
- * it is never an authorization input: each entry carries its own signed
133
- * `mutationJWT`, verified independently.
134
- *
135
- * `undefined` when the frame opened at a leaf that could not be named. The
136
- * entries still apply — see the loopback guard.
137
- */
138
- senderPeerDID: string | undefined;
139
- } | {
140
- /**
141
- * A device advertises itself to its co-members. Everything here except
142
- * `announcerDID` came off the wire; `announcerDID` did NOT — it is the
143
- * MLS-authenticated sender the handler read from `message.payload.iss`,
144
- * because the announce body carries no DID field at all.
145
- *
146
- * `undefined` when the frame opened at a leaf that could not be named, and
147
- * the apply then writes nothing: a capability claim attributed to nobody is
148
- * worse than no row.
149
- */
150
- type: 'peer:announce';
151
- label: string;
152
- availability: PeerAvailability;
153
- capabilities: Array<PeerCapability>;
154
- hlc: string;
155
- announcerDID: string | undefined;
156
- };
14
+ import type { ApplyLedgerEntriesParams } from './ledger-adopt.js';
15
+ import type { AffectedRow } from './ledger-ingest.js';
157
16
  export type { AffectedRow, LedgerAffected } from './ledger-ingest.js';
158
17
  export type ProcessBroadcastResult = {
159
18
  applied: boolean;
@@ -200,6 +59,19 @@ export type ProcessBroadcastParams = {
200
59
  */
201
60
  delegationStore: DelegationStoreAPI;
202
61
  graphStore: GraphStoreAPI;
62
+ /**
63
+ * Credential store for `credential:key-grant`. Optional because the store is
64
+ * registered by `@kubun/plugin-credential`, not by this package: a device
65
+ * without that plugin has nowhere to put a grant and drops the frame rather
66
+ * than migrating a store nothing else on the device uses.
67
+ */
68
+ credentialStore?: CredentialStoreAPI;
69
+ /**
70
+ * The engine's future-drift bound, applied to a `credential:key-grant`'s
71
+ * stamps. Optional: absence falls back to {@link DEFAULT_MAX_DRIFT_MS}, the
72
+ * same number the engine defaults to, so it never means an absent bound.
73
+ */
74
+ maxDriftMS?: number;
203
75
  /**
204
76
  * Engine graph internals — required to apply `mutation:apply` entries via
205
77
  * {@link GraphInternals.applyVerifiedMutation} with `origin: 'peer'`, which
@@ -302,122 +174,3 @@ export type ProcessBroadcastParams = {
302
174
  * payload fidelity.
303
175
  */
304
176
  export declare function processBroadcast(params: ProcessBroadcastParams, message: GroupBroadcastMessage, groupID: string): Promise<ProcessBroadcastResult>;
305
- /**
306
- * Sender-bound, LWW apply of a signed `access-default:set` frame. Shared by the
307
- * live broadcast path and the invite-seeding path so both authenticate through
308
- * the same token verification. The authoritative fields come from the verified
309
- * token; the plaintext `ownerDID` is used only for the sender-bound equality
310
- * check. Returns whether a row was written.
311
- */
312
- export type AccessDefaultSetFrame = {
313
- ownerDID: string;
314
- modelID: string;
315
- permissionType: 'read' | 'write';
316
- rule: AccessDefaultRule;
317
- hlc: string;
318
- auth: string;
319
- };
320
- export type ApplyAccessDefaultSetFrameParams = {
321
- graphStore: GraphStoreAPI;
322
- frame: AccessDefaultSetFrame;
323
- logger?: Logger;
324
- /** Scopes the log lines to the group the frame arrived on, when it arrived on one. */
325
- groupID?: string;
326
- };
327
- export declare function applyAccessDefaultSetFrame(params: ApplyAccessDefaultSetFrameParams): Promise<boolean>;
328
- /**
329
- * Apply one signed access-default set token, with no surrounding frame.
330
- *
331
- * What a catch-up reply carries: a token and nothing else, so there is no
332
- * plaintext mirror to cross-check and none is needed — the frame's mirror
333
- * fields exist for a receiver rebuilding what was signed, and the authoritative
334
- * values were always the claim's. Every other rule is the live path's: the owner
335
- * is the authenticated issuer, and the write happens only if strictly newer than
336
- * what is stored.
337
- */
338
- export declare function applyAccessDefaultSetToken(params: {
339
- graphStore: GraphStoreAPI;
340
- token: string;
341
- logger?: Logger;
342
- groupID: string;
343
- }): Promise<boolean>;
344
- /** Summary of a whole-ledger adoption. */
345
- type ApplyLedgerCatchupReplyResult = {
346
- /** Size of the ledger that was adopted. */
347
- applied: number;
348
- /**
349
- * Always `0` on this path, and structurally so: the install verifies the whole
350
- * ledger against the authenticated head before folding a single entry, so a
351
- * bad token rejects the batch rather than dropping an entry out of it. Carried
352
- * because the catch-up summary reports it beside counts from paths that CAN
353
- * drop per entry.
354
- */
355
- rejected: number;
356
- };
357
- /** Inputs the ledger-fold helpers need — a strict subset of the receive params. */
358
- export type ApplyLedgerEntriesParams = LedgerIngestParams & {
359
- /**
360
- * Run the projection rebuild atomically, given the store to write it through.
361
- *
362
- * A rebuild materializes several tables from one fold — circle rows, every
363
- * circle's `open` flag, the group row, the commit-hub designation — and a crash
364
- * between them leaves them disagreeing. A device with no flagged commit hub
365
- * cannot commit at all, which reads as a hang rather than an error.
366
- *
367
- * A seam rather than a `StoreProvider` because the surrounding operations must
368
- * NOT be in the same transaction: an install or a gather waits on the network,
369
- * and a transaction spanning one holds kubun's single SQLite connection across
370
- * it. So the caller decides what the transaction covers. Omitted, the rebuild
371
- * runs on {@link LedgerIngestParams.p2pStore} unwrapped — correct when the
372
- * caller already holds a transaction, and self-healing on the bootstrap paths,
373
- * where the next reproject rebuilds from the same ledger anyway.
374
- *
375
- * Declared here rather than on {@link LedgerIngestParams} because only the
376
- * batch folds below read it: `ingestVerifiedLedgerEntry` reprojects through
377
- * `p2pStore` directly, so a caller passing this to the single-entry path would
378
- * be promised an atomicity it does not get.
379
- */
380
- withProjectionTransaction?: (run: (store: P2PStoreAPI) => Promise<void>) => Promise<void>;
381
- };
382
- /**
383
- * Adopt the control state a handle already holds: merge the HLCs its entries
384
- * carry into this device's clock, then rebuild every projection from them.
385
- *
386
- * For a peer whose handle was filled by something other than a landed commit —
387
- * a joiner, whose Welcome folded the group's whole ledger and checked it against
388
- * the authenticated head before this device had any say. Those entries never
389
- * passed through the receive path, so nothing else has observed their clocks or
390
- * projected them.
391
- *
392
- * The merge keeps this device's later writes sorting after what the group did
393
- * before it arrived. It decides no authority — that reads the enacted position —
394
- * but the LWW comparisons that are not ledger folds (removal timestamps,
395
- * access-default rules) still depend on a clock that has seen the group's.
396
- */
397
- export declare function adoptHandleLedgerState(params: ApplyLedgerEntriesParams, groupID: string): Promise<void>;
398
- /** Inputs {@link bootstrapGroupLedger} needs on top of the ingest's. */
399
- export type BootstrapGroupLedgerParams = ApplyLedgerEntriesParams & {
400
- /**
401
- * Install a gathered WHOLE ledger into the group's handle, verified against
402
- * the authenticated head before a single entry is folded, and answer with the
403
- * roster the install left the handle holding. Throws when the list does not
404
- * reproduce that head.
405
- *
406
- * The roster is returned rather than read back afterwards so it is the one the
407
- * install itself produced, taken while the handle is still held: a second read
408
- * could observe a handle another operation has since advanced.
409
- */
410
- installGroupLedger: (groupID: string, tokens: Array<string>) => Promise<RosterState>;
411
- };
412
- /**
413
- * Adopt a whole ledger gathered from one untrusted responder.
414
- *
415
- * Signatures do NOT cover omission or reordering — a responder can hand back
416
- * genuine tokens with a demotion missing — so the list is installed against the
417
- * authenticated head FIRST and rejected whole on mismatch, before anything is
418
- * projected. Roles are reconciled against the roster the install folded, not the
419
- * ledger, because nothing later revisits a stale `admin` row.
420
- *
421
- * Throws whatever the install throws — a rejected ledger changes nothing.
422
- */
423
- export declare function bootstrapGroupLedger(params: BootstrapGroupLedgerParams, tokens: Array<string>, groupID: string): Promise<ApplyLedgerCatchupReplyResult>;
@@ -1,14 +1,12 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
- import { HLC } from '@kubun/hlc';
2
+ import { DEFAULT_MAX_DRIFT_MS } from '@kubun/mutation';
3
3
  import { toISO } from '../context/types.js';
4
4
  import { evaluateAndForward, rethrowIfProgrammerError } from '../sync/forwarder.js';
5
5
  import { createReceiveAccessGate } from '../sync/receive-access-gate.js';
6
+ import { applyAccessDefaultSetFrame } from './access-default-apply.js';
6
7
  import { verifyAccessDefault } from './access-default-token.js';
7
8
  import { verifyCatalog } from './catalog-token.js';
8
- import { reprojectCircleDefs, reprojectCircleMembers, reprojectGroupSettings } from './circle-projection.js';
9
- import { entryOrd } from './ledger.js';
10
- import { resolveGroupAnchor } from './ledger-ingest.js';
11
- import { mirrorRosterRoles } from './roster-projection.js';
9
+ import { applyCredentialKeyGrantFrame } from './credential-apply.js';
12
10
  import { storeReceivedGrant } from './store-received-grant.js';
13
11
  import { storeReceivedRevocation } from './store-received-revocation.js';
14
12
  /**
@@ -221,6 +219,33 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
221
219
  applied: true
222
220
  };
223
221
  }
222
+ case 'credential:key-grant':
223
+ {
224
+ if (params.credentialStore == null) {
225
+ params.logger?.warn('credential:key-grant dropped: no credential store on this device', {
226
+ groupID
227
+ });
228
+ return {
229
+ applied: false
230
+ };
231
+ }
232
+ const applied = await applyCredentialKeyGrantFrame({
233
+ store: params.credentialStore,
234
+ frame: message,
235
+ selfDID: params.selfDID,
236
+ maxDriftMS: params.maxDriftMS ?? DEFAULT_MAX_DRIFT_MS,
237
+ ...params.hlc != null ? {
238
+ hlc: params.hlc
239
+ } : {},
240
+ ...params.logger != null ? {
241
+ logger: params.logger
242
+ } : {},
243
+ groupID
244
+ });
245
+ return {
246
+ applied
247
+ };
248
+ }
224
249
  case 'delegation:share':
225
250
  {
226
251
  // Co-members relay the same broadcast but only the addressed device
@@ -501,202 +526,3 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
501
526
  };
502
527
  }
503
528
  }
504
- export async function applyAccessDefaultSetFrame(params) {
505
- const { graphStore, frame, logger, groupID } = params;
506
- // Authenticate via the signed token: a forged or unsigned token verifies to
507
- // null and is dropped. Every authoritative field comes from the verified
508
- // payload, never the surrounding plaintext frame.
509
- const verified = await verifyAccessDefault(frame.auth);
510
- if (verified == null || !isSetClaim(verified)) {
511
- logger?.warn('access-default:set dropped: token verification failed', {
512
- groupID
513
- });
514
- return false;
515
- }
516
- // Sender-bound: a member states only its OWN sharing policy. The row owner is
517
- // the authenticated issuer; a frame claiming a foreign owner is dropped. The
518
- // issuer is already normalized, so the plaintext owner must be too — an
519
- // equivalent DID in another form is the same principal.
520
- if (normalizeDID(frame.ownerDID) !== verified.issuer) {
521
- logger?.warn('access-default:set dropped: owner is not the authenticated author', {
522
- groupID
523
- });
524
- return false;
525
- }
526
- return await applyVerifiedAccessDefaultSet(graphStore, verified);
527
- }
528
- /**
529
- * Apply one signed access-default set token, with no surrounding frame.
530
- *
531
- * What a catch-up reply carries: a token and nothing else, so there is no
532
- * plaintext mirror to cross-check and none is needed — the frame's mirror
533
- * fields exist for a receiver rebuilding what was signed, and the authoritative
534
- * values were always the claim's. Every other rule is the live path's: the owner
535
- * is the authenticated issuer, and the write happens only if strictly newer than
536
- * what is stored.
537
- */ export async function applyAccessDefaultSetToken(params) {
538
- const verified = await verifyAccessDefault(params.token);
539
- if (verified == null || !isSetClaim(verified)) {
540
- params.logger?.warn('access-default catch-up entry dropped: verification failed', {
541
- groupID: params.groupID
542
- });
543
- return false;
544
- }
545
- return await applyVerifiedAccessDefaultSet(params.graphStore, verified);
546
- }
547
- /**
548
- * A verified access-default that states a rule rather than withdrawing one.
549
- * Written as a guard because the union discriminates on a NESTED field, which a
550
- * `verified.claim.op !== 'set'` check does not narrow the parent by.
551
- */ function isSetClaim(verified) {
552
- return verified.claim.op === 'set';
553
- }
554
- async function applyVerifiedAccessDefaultSet(graphStore, verified) {
555
- const claim = verified.claim;
556
- // LWW: apply only when strictly newer than the stored rule's anchor. An
557
- // unstamped stored rule (null hlc) is treated as oldest, so any stamped
558
- // incoming rule wins.
559
- const storedHLC = await graphStore.getUserModelAccessDefaultHLC(verified.issuer, claim.modelID, claim.permissionType);
560
- if (storedHLC != null && claim.hlc <= storedHLC) {
561
- return false;
562
- }
563
- await graphStore.setUserModelAccessDefault({
564
- ownerDID: verified.issuer,
565
- modelID: claim.modelID,
566
- permissionType: claim.permissionType,
567
- // Narrowed by `isRule` at verification, which refuses any level outside the
568
- // three the read predicate is written for.
569
- accessLevel: claim.rule.level,
570
- allowedDIDs: claim.rule.allowedDIDs,
571
- allowedCircles: claim.rule.allowedCircles,
572
- allowedGroups: claim.rule.allowedGroups,
573
- hlc: claim.hlc
574
- });
575
- return true;
576
- }
577
- /**
578
- * Adopt the control state a handle already holds: merge the HLCs its entries
579
- * carry into this device's clock, then rebuild every projection from them.
580
- *
581
- * For a peer whose handle was filled by something other than a landed commit —
582
- * a joiner, whose Welcome folded the group's whole ledger and checked it against
583
- * the authenticated head before this device had any say. Those entries never
584
- * passed through the receive path, so nothing else has observed their clocks or
585
- * projected them.
586
- *
587
- * The merge keeps this device's later writes sorting after what the group did
588
- * before it arrived. It decides no authority — that reads the enacted position —
589
- * but the LWW comparisons that are not ledger folds (removal timestamps,
590
- * access-default rules) still depend on a clock that has seen the group's.
591
- */ export async function adoptHandleLedgerState(params, groupID) {
592
- const { hlc, logger } = params;
593
- if (hlc != null && params.getGroupLedger != null) {
594
- let ledger;
595
- try {
596
- ledger = await params.getGroupLedger(groupID);
597
- } catch (error) {
598
- // The reproject below reads the ledger through the same resolver and
599
- // defers on its own when it cannot; losing the merge is not worth failing
600
- // a join over.
601
- logger?.warn('ledger clock merge skipped: ledger unreadable', {
602
- groupID,
603
- error
604
- });
605
- ledger = [];
606
- }
607
- for (const { verified } of ledger){
608
- // An entry `@kumiai/mls` minted itself carries no `ord` — it orders by the
609
- // epoch chain and never stamps kubun's clock. Nothing to merge.
610
- const ord = entryOrd(verified.entry);
611
- if (ord == null) {
612
- continue;
613
- }
614
- try {
615
- hlc.receive(HLC.parse(ord));
616
- } catch (error) {
617
- logger?.warn('ledger entry HLC merge failed', {
618
- groupID,
619
- hlc: ord,
620
- error
621
- });
622
- }
623
- }
624
- }
625
- await reprojectControlProjections(params, groupID);
626
- }
627
- /**
628
- * Rebuild every control projection from the group's whole ledger.
629
- *
630
- * A batch may carry any mix of entry types, so all three rebuild unconditionally
631
- * rather than the caller working out which were touched; reprojecting a type the
632
- * batch didn't move is a no-op that re-confirms the current rows.
633
- */ async function reprojectControlProjections(params, groupID) {
634
- const { p2pStore, logger } = params;
635
- const resolution = await resolveGroupAnchor(params, groupID);
636
- if (resolution.status !== 'anchored') {
637
- logger?.debug('ledger batch applied; projection deferred', {
638
- groupID,
639
- reason: resolution.status === 'unknown' ? 'anchor unavailable' : 'no genesis anchor'
640
- });
641
- return;
642
- }
643
- const { anchor, ledger } = resolution;
644
- // Sequential, and in this order: the circle rebuilds upsert each row's `open`
645
- // from the circle fold, then the settings rebuild overwrites `open` from the
646
- // authoritative open set. Running them concurrently, or settings first, lets
647
- // the circle upsert win and delists nothing.
648
- const rebuild = async (store)=>{
649
- await reprojectCircleDefs({
650
- store,
651
- groupID,
652
- anchor,
653
- ledger,
654
- logger
655
- });
656
- await reprojectCircleMembers({
657
- store,
658
- groupID,
659
- anchor,
660
- ledger,
661
- logger
662
- });
663
- await reprojectGroupSettings({
664
- store,
665
- groupID,
666
- anchor,
667
- ledger,
668
- logger
669
- });
670
- };
671
- if (params.withProjectionTransaction != null) {
672
- await params.withProjectionTransaction(rebuild);
673
- } else {
674
- await rebuild(p2pStore);
675
- }
676
- }
677
- /**
678
- * Adopt a whole ledger gathered from one untrusted responder.
679
- *
680
- * Signatures do NOT cover omission or reordering — a responder can hand back
681
- * genuine tokens with a demotion missing — so the list is installed against the
682
- * authenticated head FIRST and rejected whole on mismatch, before anything is
683
- * projected. Roles are reconciled against the roster the install folded, not the
684
- * ledger, because nothing later revisits a stale `admin` row.
685
- *
686
- * Throws whatever the install throws — a rejected ledger changes nothing.
687
- */ export async function bootstrapGroupLedger(params, tokens, groupID) {
688
- const roster = await params.installGroupLedger(groupID, tokens);
689
- await reprojectControlProjections(params, groupID);
690
- const members = await params.p2pStore.listGroupMembers(groupID);
691
- await mirrorRosterRoles({
692
- store: params.p2pStore,
693
- groupID,
694
- roster,
695
- memberDIDs: members.map((member)=>member.member_did),
696
- members
697
- });
698
- return {
699
- applied: tokens.length,
700
- rejected: 0
701
- };
702
- }
@@ -0,0 +1,82 @@
1
+ import { HLC } from '@kubun/hlc';
2
+ import type { Logger } from '@kubun/logger';
3
+ import type { CredentialStoreAPI } from '@kubun/store-credential';
4
+ import type { CredentialKeyBundle, GroupBroadcastMessage } from './broadcast-message.js';
5
+ export type ApplyCredentialKeyGrantFrameParams = {
6
+ store: CredentialStoreAPI;
7
+ frame: Extract<GroupBroadcastMessage, {
8
+ type: 'credential:key-grant';
9
+ }>;
10
+ /** This device, for the "is this addressed to me?" question. */
11
+ selfDID: string;
12
+ /**
13
+ * Device-wide monotonic clock, advanced past every applied entry's stamp so a
14
+ * later local credential write out-orders what was just received. Optional:
15
+ * tests that only assert stored rows omit it.
16
+ */
17
+ hlc?: HLC;
18
+ /**
19
+ * How far into the future the granter's entry stamps may sit. The engine's
20
+ * bound, threaded here rather than configured separately: this lane writes the
21
+ * granter's stamp verbatim, so without it a peer could put an unreachable
22
+ * future into rows the device then floors its clock to.
23
+ */
24
+ maxDriftMS: number;
25
+ logger?: Logger;
26
+ groupID?: string;
27
+ };
28
+ /**
29
+ * Apply a signed `credential:key-grant`: the key's public record, the one
30
+ * wrapping addressed to this device, and every entry ciphertext at that version.
31
+ *
32
+ * Sender-binding here is DIRECTIONAL, unlike `access-default:set`'s. That rule
33
+ * ("the stored owner equals the verified issuer") presumes a row, and a grant is
34
+ * the first thing this device ever hears about the key:
35
+ *
36
+ * - key absent → MINT, with `owner_did` from the verified issuer. Accepting a
37
+ * key means accepting its signer as the principal who may rotate and revoke it.
38
+ * - key present → REQUIRE the stored `owner_did` to equal the issuer. That is
39
+ * what makes a re-sent frame a retry rather than a takeover: the original
40
+ * granter updates, anyone else is refused.
41
+ *
42
+ * Returns whether anything was written.
43
+ */
44
+ export declare function applyCredentialKeyGrantFrame(params: ApplyCredentialKeyGrantFrameParams): Promise<boolean>;
45
+ /** A revoked subject's tombstone as the reconcile lane ships it. */
46
+ export type CredentialReconcileTombstone = {
47
+ keyID: string;
48
+ authorDID: string;
49
+ hlc: string;
50
+ opJWT: string;
51
+ opHash: string;
52
+ subjectID: string;
53
+ };
54
+ export type ApplyCredentialReconcileParams = {
55
+ store: CredentialStoreAPI;
56
+ /** This device, for the per-wrapping addressed-to-me check. */
57
+ selfDID: string;
58
+ /** Catch-up bundles for keys this device is a recipient of and was missing. */
59
+ bundles: Array<CredentialKeyBundle>;
60
+ /** Tombstones for advertised held IDs the server reports revoked. */
61
+ tombstones: Array<CredentialReconcileTombstone>;
62
+ hlc?: HLC;
63
+ /** Future-drift bound on the granter's stamps. Defaults to {@link DEFAULT_MAX_DRIFT_MS}. */
64
+ maxDriftMS?: number;
65
+ logger?: Logger;
66
+ };
67
+ export type ApplyCredentialReconcileResult = {
68
+ /** Bundles that wrote rows. */
69
+ applied: number;
70
+ /** Tombstones that deleted a row. */
71
+ tombstoned: number;
72
+ };
73
+ /**
74
+ * Apply a credential reconcile response: materialise the catch-up bundles a
75
+ * recipient missed, then GC the rows a tombstone revokes.
76
+ *
77
+ * The server's scoping is NOT trusted. Every row re-verifies its op signature
78
+ * and every wrapping re-checks it is addressed to this device before a write,
79
+ * exactly as an unsolicited grant would — a reconcile is a batch of the same
80
+ * self-authenticating rows, pulled instead of pushed.
81
+ */
82
+ export declare function applyCredentialReconcile(params: ApplyCredentialReconcileParams): Promise<ApplyCredentialReconcileResult>;