@kubun/plugin-p2p 0.11.0 → 0.12.1

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 (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +59 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -14,26 +14,40 @@ import { defineGroupProtocol } from '@kumiai/rpc';
14
14
  * self-authenticating at the payload level (signed tokens / HLC-stamped
15
15
  * records), so the lane provides ordering + delivery, not authorization.
16
16
  */ export const controlProtocol = defineGroupProtocol({
17
- 'control/ledgerEntry': {
18
- type: 'event',
19
- description: 'A self-signed control-ledger entry appended by its author.',
20
- data: {
17
+ 'control/ledgerCatchup': {
18
+ type: 'request',
19
+ retain: 'ephemeral',
20
+ description: 'Ask current members for the group control ledger to backfill missed entries. Gathered: each member replies with its entries; the requester re-verifies every token.',
21
+ param: {
22
+ type: 'object',
23
+ properties: {},
24
+ additionalProperties: false
25
+ },
26
+ result: {
21
27
  type: 'object',
22
28
  properties: {
23
- token: {
24
- type: 'string',
25
- description: 'Signed control-ledger entry token.'
29
+ entries: {
30
+ type: 'array',
31
+ items: {
32
+ type: 'string'
33
+ },
34
+ description: 'Signed control-ledger entry tokens.'
26
35
  }
27
36
  },
28
37
  required: [
29
- 'token'
38
+ 'entries'
30
39
  ],
31
40
  additionalProperties: false
32
41
  }
33
42
  },
34
- 'control/ledgerCatchup': {
43
+ 'control/policyCatchup': {
35
44
  type: 'request',
36
- description: 'Ask current members for the group control ledger to backfill missed entries. Gathered: each member replies with its entries; the requester re-verifies every token.',
45
+ // Nothing kept: the reply is a snapshot of what each responder's rules are
46
+ // right now, and one that outlived its window would be answering for a
47
+ // policy that may since have been rewritten. The durable half of this lane
48
+ // is `control/accessDefaultSet`, which this repairs rather than replaces.
49
+ retain: 'ephemeral',
50
+ description: 'Ask current members to restate their OWN model access-defaults, for a member whose read position fell below the hub retention floor and can no longer read the frames that carried them. Gathered: each member replies with signed tokens for its own rules; the requester verifies every token and applies it under the same sender-bound LWW rules as a live frame.',
37
51
  param: {
38
52
  type: 'object',
39
53
  properties: {},
@@ -42,22 +56,23 @@ import { defineGroupProtocol } from '@kumiai/rpc';
42
56
  result: {
43
57
  type: 'object',
44
58
  properties: {
45
- entries: {
59
+ defaults: {
46
60
  type: 'array',
47
61
  items: {
48
62
  type: 'string'
49
63
  },
50
- description: 'Signed control-ledger entry tokens.'
64
+ description: 'Signed access-default set tokens, one per rule the responder owns.'
51
65
  }
52
66
  },
53
67
  required: [
54
- 'entries'
68
+ 'defaults'
55
69
  ],
56
70
  additionalProperties: false
57
71
  }
58
72
  },
59
73
  'control/delegationShare': {
60
74
  type: 'event',
75
+ retain: 'log',
61
76
  description: 'Distribute a minted document/write capability token to group co-members.',
62
77
  data: {
63
78
  type: 'object',
@@ -80,6 +95,7 @@ import { defineGroupProtocol } from '@kumiai/rpc';
80
95
  },
81
96
  'control/delegationRevoke': {
82
97
  type: 'event',
98
+ retain: 'log',
83
99
  description: 'Distribute a signed revocation record for a previously minted capability.',
84
100
  data: {
85
101
  type: 'object',
@@ -100,8 +116,142 @@ import { defineGroupProtocol } from '@kumiai/rpc';
100
116
  additionalProperties: false
101
117
  }
102
118
  },
119
+ 'control/accessDefaultSet': {
120
+ type: 'event',
121
+ // Durable policy, not a notification: a member offline while the owner
122
+ // rewrites its sharing rule must learn it on return, or it keeps serving
123
+ // that owner's documents under a rule the owner replaced. Retained like the
124
+ // delegation frames, and for the same reason.
125
+ retain: 'log',
126
+ description: "Distribute a member's own model access-default to co-members. `auth` is the signed statement; every other field mirrors it so the receiver can rebuild the frame before verifying.",
127
+ data: {
128
+ type: 'object',
129
+ properties: {
130
+ modelID: {
131
+ type: 'string'
132
+ },
133
+ permissionType: {
134
+ type: 'string',
135
+ enum: [
136
+ 'read',
137
+ 'write'
138
+ ]
139
+ },
140
+ rule: {
141
+ type: 'object',
142
+ properties: {
143
+ level: {
144
+ type: 'string'
145
+ },
146
+ allowedDIDs: {
147
+ type: [
148
+ 'array',
149
+ 'null'
150
+ ],
151
+ items: {
152
+ type: 'string'
153
+ }
154
+ },
155
+ allowedCircles: {
156
+ type: [
157
+ 'array',
158
+ 'null'
159
+ ],
160
+ items: {
161
+ type: 'string'
162
+ }
163
+ },
164
+ allowedGroups: {
165
+ type: [
166
+ 'array',
167
+ 'null'
168
+ ],
169
+ items: {
170
+ type: 'string'
171
+ }
172
+ }
173
+ },
174
+ required: [
175
+ 'level',
176
+ 'allowedDIDs',
177
+ 'allowedCircles',
178
+ 'allowedGroups'
179
+ ],
180
+ additionalProperties: false
181
+ },
182
+ ownerDID: {
183
+ type: 'string',
184
+ description: 'Claimed owner; checked against the `auth` issuer.'
185
+ },
186
+ hlc: {
187
+ type: 'string',
188
+ description: 'Owner-stamped LWW anchor, mirroring the stored row.'
189
+ },
190
+ auth: {
191
+ type: 'string',
192
+ description: 'Signed access-default token.'
193
+ }
194
+ },
195
+ required: [
196
+ 'modelID',
197
+ 'permissionType',
198
+ 'rule',
199
+ 'ownerDID',
200
+ 'hlc',
201
+ 'auth'
202
+ ],
203
+ additionalProperties: false
204
+ }
205
+ },
206
+ 'control/accessDefaultRemove': {
207
+ type: 'event',
208
+ // Same retention as the set, and load-bearing in the other direction: an
209
+ // ephemeral removal would distribute every widening and swallow the
210
+ // narrowings, leaving an absent member serving under a withdrawn rule.
211
+ retain: 'log',
212
+ description: "Distribute a member's own model access-default removal to co-members.",
213
+ data: {
214
+ type: 'object',
215
+ properties: {
216
+ modelID: {
217
+ type: 'string'
218
+ },
219
+ permissionTypes: {
220
+ type: 'array',
221
+ items: {
222
+ type: 'string',
223
+ enum: [
224
+ 'read',
225
+ 'write'
226
+ ]
227
+ }
228
+ },
229
+ ownerDID: {
230
+ type: 'string',
231
+ description: 'Claimed owner; checked against the `auth` issuer.'
232
+ },
233
+ hlc: {
234
+ type: 'string',
235
+ description: 'Owner-stamped LWW anchor for the removal.'
236
+ },
237
+ auth: {
238
+ type: 'string',
239
+ description: 'Signed access-default removal token.'
240
+ }
241
+ },
242
+ required: [
243
+ 'modelID',
244
+ 'permissionTypes',
245
+ 'ownerDID',
246
+ 'hlc',
247
+ 'auth'
248
+ ],
249
+ additionalProperties: false
250
+ }
251
+ },
103
252
  'control/groupLeaveRequest': {
104
253
  type: 'event',
254
+ retain: 'ephemeral',
105
255
  description: 'Advisory voluntary-leave notice. Not authoritative — an admin must issue a real removeMember; receivers surface it as an event and never tombstone on it.',
106
256
  data: {
107
257
  type: 'object',
@@ -128,6 +278,7 @@ import { defineGroupProtocol } from '@kumiai/rpc';
128
278
  */ export const syncProtocol = defineGroupProtocol({
129
279
  'sync/mutationApply': {
130
280
  type: 'event',
281
+ retain: 'ephemeral',
131
282
  description: 'Broadcast a batch of signed document mutations to group members.',
132
283
  data: {
133
284
  type: 'object',
@@ -168,7 +319,130 @@ import { defineGroupProtocol } from '@kumiai/rpc';
168
319
  }
169
320
  }
170
321
  });
171
- /** The two app-lane protocols, keyed for `createGroupPeer({ protocols })`. */ export const groupProtocols = {
322
+ /**
323
+ * Peer plane: what each device advertises about itself to its co-members. Its
324
+ * own protocol rather than a third control procedure, so its traffic and
325
+ * retention floor stay independent of the control ledger's — the topic derives
326
+ * per protocol NAME (`protocolTopic(secret, epoch, name)`), so a new name can
327
+ * only add a lane.
328
+ *
329
+ * ATTRIBUTION IS THE LANE'S, NOT THE BODY'S: `peer/announce` carries no DID
330
+ * field and the receiver keys the row on `message.payload.iss`, the
331
+ * MLS-authenticated sender. A convenience `peerDID` in the body would let any
332
+ * member write any other member's row — a defect this project has shipped once.
333
+ */ /**
334
+ * What a device says about itself. ONE schema object, used as `peer/announce`'s
335
+ * body and as `peer/query`'s result: a query reply IS an announcement, delivered
336
+ * on demand, and two schemas that happen to agree today would drift the first
337
+ * time one of them gained a field.
338
+ */ const peerAnnouncementBody = {
339
+ type: 'object',
340
+ properties: {
341
+ label: {
342
+ type: 'string',
343
+ description: 'Self-declared display hint, Bluetooth-style. Rendered beside the DID, never read as an identity.'
344
+ },
345
+ availability: {
346
+ type: 'string',
347
+ enum: [
348
+ 'always-on',
349
+ 'interactive',
350
+ 'mobile'
351
+ ],
352
+ description: 'Steers automatic selection only; it never refuses to serve.'
353
+ },
354
+ capabilities: {
355
+ type: 'array',
356
+ items: {
357
+ type: 'object',
358
+ properties: {
359
+ protocol: {
360
+ type: 'string'
361
+ },
362
+ version: {
363
+ type: 'number'
364
+ },
365
+ transports: {
366
+ type: [
367
+ 'array',
368
+ 'null'
369
+ ],
370
+ items: {
371
+ type: 'string'
372
+ },
373
+ description: 'Addressing hints for a capability the hub tunnel does not already reach. Null for one it does.'
374
+ }
375
+ },
376
+ // Nullable-and-required rather than optional, as the access-default
377
+ // rule's arms are: an absent key and an explicit null would otherwise
378
+ // be two encodings of one fact, and only one of them round-trips.
379
+ required: [
380
+ 'protocol',
381
+ 'version',
382
+ 'transports'
383
+ ],
384
+ additionalProperties: false
385
+ },
386
+ description: 'Matched EXACTLY on protocol AND version.'
387
+ },
388
+ hlc: {
389
+ type: 'string',
390
+ description: 'Announcer-stamped LWW anchor for its own projection row.'
391
+ }
392
+ },
393
+ required: [
394
+ 'label',
395
+ 'availability',
396
+ 'capabilities',
397
+ 'hlc'
398
+ ],
399
+ additionalProperties: false
400
+ };
401
+ export const peerProtocol = defineGroupProtocol({
402
+ 'peer/announce': {
403
+ type: 'event',
404
+ // Durable state, not a notification. A device that was offline (or was added
405
+ // at a later epoch, and so was never on the old topic) has no other way to
406
+ // learn who else is reachable: the projection is the source of truth and this
407
+ // lane is what fills it.
408
+ retain: 'log',
409
+ description: 'Advertise this device to its co-members: display label, availability class, and the exact capabilities it answers on. The announcer is taken from the authenticated sender, so the body names nobody.',
410
+ data: peerAnnouncementBody
411
+ },
412
+ 'peer/query': {
413
+ type: 'request',
414
+ // Liveness, so nothing is kept: a reply that outlived its window would be
415
+ // answering a question nobody is still asking, and the durable half of this
416
+ // lane is `peer/announce`.
417
+ retain: 'ephemeral',
418
+ description: 'Ask who is reachable right now, optionally narrowed to devices answering named protocols. Gathered: every matching device replies with its own advertisement within the window. A result is "who answered", never "who exists".',
419
+ param: {
420
+ type: 'object',
421
+ properties: {
422
+ protocols: {
423
+ type: [
424
+ 'array',
425
+ 'null'
426
+ ],
427
+ items: {
428
+ type: 'string'
429
+ },
430
+ description: "Only devices answering one of these reply. Null means any. Nullable-and-required, as the announce body's `transports` is."
431
+ }
432
+ },
433
+ required: [
434
+ 'protocols'
435
+ ],
436
+ additionalProperties: false
437
+ },
438
+ result: peerAnnouncementBody
439
+ }
440
+ });
441
+ /**
442
+ * The app-lane protocols, keyed for `createGroupPeer({ protocols })`. Each key
443
+ * names its own topic, so adding one moves no existing lane.
444
+ */ export const groupProtocols = {
172
445
  control: controlProtocol,
173
- sync: syncProtocol
446
+ sync: syncProtocol,
447
+ peer: peerProtocol
174
448
  };
@@ -1,4 +1,36 @@
1
+ import type { CatalogRecord } from '@kubun/protocol';
1
2
  import type { Invite, KeyPackageBundle } from '@kumiai/mls';
3
+ import type { AccessDefaultRule } from './access-default-token.js';
4
+ /**
5
+ * The inviter's own model access-default, carried as a self-authenticating seed
6
+ * so the joiner holds it before any broadcast arrives. `auth` is a token signed
7
+ * by the owner; the joiner derives the authoritative fields from it and applies
8
+ * the rule only when `ownerDID` equals the verified issuer (sender-bound), LWW
9
+ * by `hlc`. Mirrors the `access-default:set` broadcast frame minus the `type`.
10
+ */
11
+ export type InviteAccessDefault = {
12
+ ownerDID: string;
13
+ modelID: string;
14
+ permissionType: 'read' | 'write';
15
+ rule: AccessDefaultRule;
16
+ hlc: string;
17
+ auth: string;
18
+ };
19
+ /**
20
+ * A group catalog seeded into a joiner, tagged with the circle whose
21
+ * `catalog_ids` referenced it. `token` is the creator's original signed
22
+ * `catalog:set` token, forwarded verbatim: the joiner re-verifies it with the
23
+ * same helper the broadcast receive path uses (creator-binding `ownerDID ===
24
+ * iss`, payload matches the signed record) before storing anything, so an
25
+ * inviter cannot inject a spoofed catalog. The joiner stores it as a KNOWN (not
26
+ * active) catalog with provenance `source_group_id` = the joined group and
27
+ * `source_circle_id` = this circle — activation stays an explicit local decision.
28
+ */
29
+ export type InviteCatalogSeed = {
30
+ catalog: CatalogRecord;
31
+ sourceCircleID: string;
32
+ token: string;
33
+ };
2
34
  export type JoinRequestPayload = {
3
35
  did: string;
4
36
  publicPackage: KeyPackageBundle['publicPackage'];
@@ -35,6 +67,22 @@ export type InvitePayload = {
35
67
  * against any caps it already holds.
36
68
  */
37
69
  revocations?: Array<string>;
70
+ /**
71
+ * The inviter's OWN model access-default rows, each carrying a token signed by
72
+ * the inviter. Applied sender-bound (owner must equal the token issuer) and
73
+ * LWW by HLC at join, so a third-party row a malicious inviter tries to inject
74
+ * cannot be signed and is dropped.
75
+ */
76
+ accessDefaults?: Array<InviteAccessDefault>;
77
+ /**
78
+ * The group's catalog records (those referenced by the group's circles), each
79
+ * tagged with the referencing circle. Stored as KNOWN (not active) catalogs at
80
+ * join — discovery applies no sync, so activation stays an explicit local
81
+ * decision. Carried as plain records: catalogs are sync-scope hints, not
82
+ * authorization, so they are trusted from the inviter like `groupName` and
83
+ * `suggestedHubs` rather than individually signed.
84
+ */
85
+ catalogs?: Array<InviteCatalogSeed>;
38
86
  };
39
87
  export declare function encodeJoinRequest(payload: JoinRequestPayload): string;
40
88
  export declare function decodeJoinRequest(encoded: string): JoinRequestPayload;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The DID of the device that authored a join request.
3
+ *
4
+ * Read from the MLS credential inside the KeyPackage, which the leaf signature
5
+ * covers; NEVER from the payload's sibling `did` field, which its author writes
6
+ * freely. Those two are one identity claim written in two places, so a
7
+ * disagreement REFUSES — resolving it in either direction would pick a winner
8
+ * silently.
9
+ *
10
+ * Every path that mints an invite resolves the recipient through here, because
11
+ * the mint hands the two halves to different subsystems: the KeyPackage becomes
12
+ * an MLS leaf and the DID becomes a membership row and a role entry. Taking the
13
+ * DID from the payload lets a forged pairing put those two names out of step —
14
+ * a leaf for one identity, an authenticated membership row for another — and no
15
+ * later fold reconciles them, because each side is doing exactly what it was
16
+ * told.
17
+ *
18
+ * An envelope-bound caller can additionally compare the result against the DID
19
+ * that signed the request, which is a check this function cannot make.
20
+ */
21
+ export declare function resolveJoinRequestDID(joinRequest: string): string;
@@ -0,0 +1,43 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { parseMLSCredentialIdentity } from '@kumiai/mls';
3
+ import { GraphQLError } from 'graphql';
4
+ import { defaultCredentialTypes, isDefaultCredential } from 'ts-mls';
5
+ import { decodeJoinRequest } from './invite-payload.js';
6
+ /**
7
+ * The DID of the device that authored a join request.
8
+ *
9
+ * Read from the MLS credential inside the KeyPackage, which the leaf signature
10
+ * covers; NEVER from the payload's sibling `did` field, which its author writes
11
+ * freely. Those two are one identity claim written in two places, so a
12
+ * disagreement REFUSES — resolving it in either direction would pick a winner
13
+ * silently.
14
+ *
15
+ * Every path that mints an invite resolves the recipient through here, because
16
+ * the mint hands the two halves to different subsystems: the KeyPackage becomes
17
+ * an MLS leaf and the DID becomes a membership row and a role entry. Taking the
18
+ * DID from the payload lets a forged pairing put those two names out of step —
19
+ * a leaf for one identity, an authenticated membership row for another — and no
20
+ * later fold reconciles them, because each side is doing exactly what it was
21
+ * told.
22
+ *
23
+ * An envelope-bound caller can additionally compare the result against the DID
24
+ * that signed the request, which is a check this function cannot make.
25
+ */ export function resolveJoinRequestDID(joinRequest) {
26
+ const decoded = decodeJoinRequest(joinRequest);
27
+ const credential = decoded.publicPackage.leafNode.credential;
28
+ // Only a basic credential carries an `identity` at all — x509 carries
29
+ // certificates and a custom one opaque bytes — so a non-basic credential binds
30
+ // no DID. Refused rather than coerced back onto the payload field.
31
+ if (!isDefaultCredential(credential) || credential.credentialType !== defaultCredentialTypes.basic) {
32
+ throw new GraphQLError(`the join request carries a non-basic MLS credential (type ${credential.credentialType}), which binds no identity`);
33
+ }
34
+ const credentialDID = parseMLSCredentialIdentity(credential.identity).id;
35
+ if (credentialDID === '') {
36
+ throw new GraphQLError('the join request credential carries no identity');
37
+ }
38
+ const payloadDID = typeof decoded.did === 'string' ? decoded.did : null;
39
+ if (payloadDID == null || normalizeDID(payloadDID) !== normalizeDID(credentialDID)) {
40
+ throw new GraphQLError(`the join request's did field (${String(payloadDID)}) disagrees with its KeyPackage credential (${credentialDID})`);
41
+ }
42
+ return credentialDID;
43
+ }
@@ -1,8 +1,11 @@
1
+ import { type OwnIdentity } from '@kokuin/token';
1
2
  import type { StoreProvider } from '@kubun/db';
2
3
  import type { HLC } from '@kubun/hlc';
3
4
  import type { Logger } from '@kubun/logger';
5
+ import type { GroupAnchor, LedgerLogEntry } from '@kumiai/mls';
4
6
  import type { GroupData } from '../types.js';
5
7
  import type { P2PEventEmitter } from './events.js';
8
+ import type { InviteAccessDefault, InviteCatalogSeed } from './invite-payload.js';
6
9
  export type FinalizeJoinedGroupParams = {
7
10
  stores: StoreProvider;
8
11
  emitter: P2PEventEmitter;
@@ -78,3 +81,60 @@ export type ApplyInviteRevocationsParams = {
78
81
  * Returns the number of revocations that resulted in a stored row.
79
82
  */
80
83
  export declare function applyInviteRevocations(params: ApplyInviteRevocationsParams): Promise<number>;
84
+ /**
85
+ * The group's control state an invite carries so a joiner is current at join.
86
+ *
87
+ * NOT the control ledger. That travels on the invite `@kumiai/mls` builds, as
88
+ * the list its own head check is computed over — a second copy assembled here
89
+ * could only agree with it or corrupt it.
90
+ */
91
+ export type InviteControlSeeds = {
92
+ accessDefaults: Array<InviteAccessDefault>;
93
+ catalogs: Array<InviteCatalogSeed>;
94
+ };
95
+ export type CollectInviteSeedsParams = {
96
+ stores: StoreProvider;
97
+ /** Inviter identity — signs the access-default seeds so they self-authenticate. */
98
+ identity: OwnIdentity;
99
+ /** Owner DID whose model access-defaults are seeded (the inviter). */
100
+ ownerDID: string;
101
+ groupID: string;
102
+ logger?: Logger;
103
+ };
104
+ /**
105
+ * Gather the group state an invite seeds into the joiner: the inviter's own
106
+ * model access-defaults (each signed so it self-authenticates), and the catalogs
107
+ * the group's circles reference. Access-default rows without an HLC anchor are
108
+ * skipped — they carry no LWW ordering and cannot be authenticated for
109
+ * replication.
110
+ */
111
+ export declare function collectInviteSeeds(params: CollectInviteSeedsParams): Promise<InviteControlSeeds>;
112
+ export type ApplyInviteControlStateParams = {
113
+ stores: StoreProvider;
114
+ groupID: string;
115
+ accessDefaults?: Array<InviteAccessDefault>;
116
+ catalogs?: Array<InviteCatalogSeed>;
117
+ /** Device-wide clock — the joined ledger's HLCs merge into it. */
118
+ hlc: HLC;
119
+ /** Resolves the group's genesis anchor so ledger entries can project at join. */
120
+ getGroupAnchor?: (groupID: string) => Promise<GroupAnchor | null>;
121
+ /** Resolves the group's control ledger, which every projection folds from. */
122
+ getGroupLedger?: (groupID: string) => Promise<ReadonlyArray<LedgerLogEntry>>;
123
+ logger?: Logger;
124
+ };
125
+ /**
126
+ * Apply the control state a join produced, after the MLS join completes.
127
+ *
128
+ * The control ledger is NOT among the parameters, deliberately: it arrived on
129
+ * the MLS invite and `processWelcome` has already checked it against the
130
+ * authenticated head and folded it into the handle, which is what every
131
+ * projection reads. All that is left is to adopt what the handle now holds. A
132
+ * second copy carried beside it could only agree or disagree, and a joiner has
133
+ * no way to tell which.
134
+ *
135
+ * The rest authenticates independently: access-defaults apply sender-bound + LWW
136
+ * via their owner-signed tokens, and catalogs upsert as plain sync-scope records
137
+ * trusted from the inviter. A third-party access-default row is dropped without
138
+ * blocking the rest.
139
+ */
140
+ export declare function applyInviteControlState(params: ApplyInviteControlStateParams): Promise<void>;