@kubun/plugin-p2p 0.11.0 → 0.12.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 (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 +56 -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
@@ -1,22 +1,38 @@
1
+ import type { GroupAnchor } from '@kumiai/mls';
1
2
  import { type AdminRoleValue } from './admin-roster.js';
2
- import type { GroupAnchor } from './group-anchor.js';
3
- import type { FoldInput, LedgerReducer } from './ledger-fold.js';
3
+ import type { LedgerReducer, PositionedFoldInput } from './ledger.js';
4
4
  /**
5
- * The fold's authority gate: was `issuer` a group admin at `hlc`? The reducer
6
- * factories take this predicate so the projection fold stays synchronous and
7
- * deterministic from the ledger alone — every peer that folds the same ledger
8
- * resolves the same authority and converges to the same state. By design this
9
- * gate is the LEDGER admin-fold ONLY: membership and MLS-removal are enforced
10
- * outside the projection (the live broadcast gate and the MLS roster), never
11
- * conjoined here, so the fold never needs an async store read.
5
+ * The fold's authority gate: was `issuer` a group admin when the entry at
6
+ * `position` was enacted?
7
+ *
8
+ * The LEDGER admin-fold ONLY membership and MLS-removal are enforced outside the
9
+ * projection, never conjoined here, so the fold stays synchronous and every peer
10
+ * folding the same ledger converges. Applied by {@link authorizedEntries} BEFORE
11
+ * the fold, not inside a reducer's `verifyAuthority`: that hook sees only the
12
+ * entry, and the prefix an entry is judged against is a property of its slot.
13
+ */
14
+ export type IsAdminAtPosition = (issuer: string, position: number) => boolean;
15
+ /**
16
+ * Drop the entries whose issuer lacked admin authority at their own slot, keeping
17
+ * the rest in ledger order.
18
+ *
19
+ * INCLUSIVE compare against the ledger prefix through the entry's own position: a
20
+ * grant authorizes from its own slot onward, a demotion stops authorizing from its
21
+ * own slot onward.
12
22
  */
13
- export type IsAdminAtHLC = (issuer: string, hlc: string) => boolean;
14
- /** Ledger entry `type` for a circle-membership claim. */
15
- export declare const CIRCLE_MEMBER_ENTRY_TYPE = "circle.member";
23
+ export declare function authorizedEntries<TValue>(entries: Array<PositionedFoldInput<TValue>>, isAdmin: IsAdminAtPosition): Array<PositionedFoldInput<TValue>>;
24
+ /**
25
+ * Ledger entry `type` for a circle-membership claim. Every control-ledger type
26
+ * kubun mints lives under `kubun.*` — that namespace is kubun's, and the split
27
+ * between "kubun projects this" and "the MLS library interprets this" is drawn
28
+ * on it. Namespaces the library reserves for itself are its own to name and
29
+ * rename, so kubun stays out of them and never restates them.
30
+ */
31
+ export declare const CIRCLE_MEMBER_ENTRY_TYPE = "kubun.circle.member";
16
32
  /**
17
33
  * The claim carried by a circle-member entry. The active form names the
18
34
  * member's role within the circle; the tombstone form removes them. Both are
19
- * whole-value: the highest-ordered entry for a subject replaces the projected
35
+ * whole-value: the last entry for a subject in the ledger replaces the projected
20
36
  * state outright, so the value alone decides active-with-role vs removed. `role`
21
37
  * mirrors the store's free-form role column (no fixed enum), so it stays a
22
38
  * string rather than inventing a circle-specific role type.
@@ -44,38 +60,12 @@ export declare function decodeCircleMemberSubject(subject: string): {
44
60
  circleID: string;
45
61
  memberDID: string;
46
62
  } | null;
47
- /**
48
- * Folded circle membership, keyed by composite subject. Only active members are
49
- * present — a subject whose highest-ordered entry is a tombstone is absent from
50
- * the map.
51
- */
52
- export type CircleMemberState = {
53
- members: Map<string, {
54
- circleID: string;
55
- memberDID: string;
56
- role: string;
57
- }>;
58
- };
59
- /**
60
- * Per-member last-writer-wins circle roster. {@link foldLedger} sorts by the
61
- * total order `(hlc, entryID)`, so each subject's highest-ordered entry is
62
- * applied last and decides the outcome: an active value sets the member with its
63
- * role, a tombstone removes them. Whole-value replacement means add / remove /
64
- * re-add converge to the same state on every peer regardless of arrival order.
65
- *
66
- * Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
67
- * HLC so a key authorized when it wrote stays authorized even after a later
68
- * revoke. The gate is the ledger admin-fold ONLY — membership and MLS-removal
69
- * are enforced outside this projection — so the fold remains synchronous and
70
- * deterministic from the ledger alone.
71
- */
72
- export declare function createCircleMemberReducer(isAdminAtHLC: IsAdminAtHLC): LedgerReducer<CircleMemberValue, CircleMemberState>;
73
63
  /** Ledger entry `type` for a circle definition (create / update / delete). */
74
- export declare const CIRCLE_DEF_ENTRY_TYPE = "circle.def";
64
+ export declare const CIRCLE_DEF_ENTRY_TYPE = "kubun.circle.def";
75
65
  /**
76
66
  * The claim carried by a circle-definition entry. The active form is the whole
77
67
  * circle definition; the tombstone form deletes it. Both are whole-value: the
78
- * highest-ordered entry for a circle replaces the projected definition outright,
68
+ * last entry for a circle in the ledger replaces the projected definition outright,
79
69
  * so create and update share one shape and the value alone decides present vs
80
70
  * deleted.
81
71
  */
@@ -87,92 +77,63 @@ export type CircleDefValue = {
87
77
  removed: true;
88
78
  };
89
79
  /**
90
- * Folded circle definitions, keyed by circleID. Only present circles appear — a
91
- * circle whose highest-ordered entry is a tombstone is absent from the map.
80
+ * Ledger entry `type` for a group-settings claim. Namespaced under `kubun.*`
81
+ * like every kubun control-ledger type a bare `group.settings` sits in the
82
+ * namespace the MLS library reserves, so a commit carrying it is refused whole.
92
83
  */
93
- export type CircleDefState = {
94
- circles: Map<string, {
95
- name: string;
96
- description: string;
97
- catalogIDs: Array<string>;
98
- }>;
99
- };
84
+ export declare const GROUP_SETTINGS_ENTRY_TYPE = "kubun.group.settings";
100
85
  /**
101
- * Per-circle last-writer-wins definition. The signed entry's `subject` is the
102
- * circleID directly, so no decode is needed. {@link foldLedger} sorts by the
103
- * total order `(hlc, entryID)`, so each circle's highest-ordered entry is
104
- * applied last and decides the outcome: an active value sets the definition, a
105
- * tombstone deletes it. Whole-value replacement means create / update / delete
106
- * converge to the same state on every peer regardless of arrival order.
86
+ * The claim carried by a group-settings entry. FIELD-WISE LWW, no tombstone: an
87
+ * omitted field leaves the prior value untouched, while `''` (or an empty list) is
88
+ * a REAL value that clears it.
107
89
  *
108
- * Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
109
- * HLC. The gate is the ledger admin-fold ONLY membership and MLS-removal are
110
- * enforced outside this projection so the fold remains synchronous and
111
- * deterministic from the ledger alone.
112
- */
113
- export declare function createCircleDefReducer(isAdminAtHLC: IsAdminAtHLC): LedgerReducer<CircleDefValue, CircleDefState>;
114
- /** Ledger entry `type` for a group-settings claim. */
115
- export declare const GROUP_SETTINGS_ENTRY_TYPE = "group.settings";
116
- /**
117
- * The claim carried by a group-settings entry. Unlike `circle.def` /
118
- * `circle.member`, this value is PARTIAL: an entry sets only the fields it
119
- * carries and stays silent about the rest. A field omitted from the
120
- * value leaves the prior value untouched; setting a field to `''` is a real
121
- * value that clears it. There is no tombstone — group settings are never
122
- * deleted, only overwritten field by field.
90
+ * `commitHub` is the group's consensus commit-hub designation the shared hub
91
+ * identity, NOT a local hub-row id. Folding it as group state is what stops two
92
+ * devices forking the group by each committing through their own first-bound hub.
123
93
  */
124
94
  export type GroupSettingsValue = {
125
95
  name?: string;
126
96
  description?: string;
97
+ openCircleIDs?: Array<string>;
98
+ commitHub?: {
99
+ url: string;
100
+ serverDID?: string;
101
+ };
127
102
  };
128
103
  /** Folded group settings, keyed by groupID (the entry subject). */
129
104
  export type GroupSettingsState = {
130
- settings: Map<string, {
131
- name?: string;
132
- description?: string;
133
- }>;
105
+ settings: Map<string, GroupSettingsValue>;
134
106
  };
135
107
  /**
136
108
  * Per-field last-writer-wins group settings. The signed entry's `subject` is the
137
- * groupID directly, so no decode is needed. {@link foldLedger} sorts by the total
138
- * order `(hlc, entryID)` and applies low→high, so for each field the
139
- * highest-ordered entry that SETS that field wins naturally.
109
+ * groupID directly, so no decode is needed. Replayed in the ledger's own
110
+ * enactment order, so for each field the LAST entry that SETS that field wins
111
+ * naturally.
140
112
  *
141
- * This reducer merges per field rather than replacing the whole value the way
142
- * `circle.def` / `circle.member` do: those project a single cohesive record
143
- * where add / remove / re-add must converge to one state, so whole-value
144
- * replacement is correct. Group settings instead accumulate independent fields
145
- * (`name`, `description`) that different entries touch in isolation, so a
146
- * name-only update must not clobber a previously-set description. Presence is
147
- * decided by `value.field !== undefined` rather than `'field' in value`: an
148
- * explicit `undefined` is treated as "not set" (no change), while `''` is a real
149
- * value that clears the field.
113
+ * Merges PER FIELD rather than replacing the whole value the way `circle.def` /
114
+ * `circle.member` do, because these fields are touched in isolation: a name-only
115
+ * update must not clobber a previously-set description. Presence is
116
+ * `value.field !== undefined`, not `'field' in value`, so an explicit `undefined`
117
+ * means no change while `''` or an empty list CLEARS the field.
150
118
  *
151
- * Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
152
- * HLC. The gate is the ledger admin-fold ONLY — membership and MLS-removal are
153
- * enforced outside this projection — so the fold remains synchronous and
154
- * deterministic from the ledger alone.
119
+ * Unauthorized entries never reach here see {@link authorizedEntries}.
155
120
  */
156
- export declare function createGroupSettingsReducer(isAdminAtHLC: IsAdminAtHLC): LedgerReducer<GroupSettingsValue, GroupSettingsState>;
121
+ export declare function createGroupSettingsReducer(): LedgerReducer<GroupSettingsValue, GroupSettingsState>;
157
122
  /**
158
- * Build an {@link IsAdminAtHLC} predicate from the group's verified admin-role
159
- * ledger entries. It re-folds the admin sub-ledger at each queried entry's HLC,
160
- * so the answer is rotation-aware (was-admin-at-HLC, not is-admin-now): a key
161
- * granted admin at HLC h authorizes entries at or after h, and a later revoke
162
- * stops authorizing entries from that point on. `foldAdminRoster`'s `atHLC`
163
- * compare is inclusive (`entry.hlc <= atHLC`), so an entry at exactly the grant
164
- * HLC sees the grant and is authorized, and an entry at exactly the revoke HLC
165
- * sees the revoke and is not.
123
+ * Build an {@link IsAdminAtPosition} predicate from the group's admin-role ledger
124
+ * entries. It re-folds the admin sub-ledger through each queried slot, so the
125
+ * answer is rotation-aware (was-admin-then, not is-admin-now): a key granted
126
+ * admin at slot n authorizes entries from slot n onward, and a later demotion
127
+ * stops authorizing from its own slot onward. The compare is inclusive
128
+ * (`position <= throughPosition`), which matters only for a role entry judging
129
+ * itself a circle entry never shares a slot with the grant that authorizes it.
166
130
  *
167
- * Callers load the verified admin entries once (via `loadVerifiedAdminEntries`)
168
- * and reuse the returned predicate across the circle / def / settings folds.
131
+ * Callers select the role entries once (via `selectRoleEntries`) and reuse the
132
+ * returned predicate across the circle / def / settings folds.
169
133
  *
170
- * Authority here is ledger-admin-only: it answers "did the ledger grant this
171
- * issuer admin at this HLC?" and does NOT check the issuer's MLS membership at
172
- * that HLC. So an admin who was removed from the MLS group but whose admin role
173
- * was never revoked on the ledger still has authorizing entries folded in.
174
- * Closing that requires removal to write a ledger revocation atomically with the
175
- * MLS-roster delta (the same MLS-commit/roster atomicity work that keeps a
176
- * removed member's writes from applying); it is deliberately out of scope here.
134
+ * LEDGER-ADMIN ONLY it does not check the issuer's MLS membership, so an admin
135
+ * removed from the MLS group whose admin role was never revoked on the ledger still
136
+ * has its entries folded in. Closing that needs removal to write a ledger
137
+ * revocation atomically with the roster delta; out of scope here.
177
138
  */
178
- export declare function adminAuthorityFromEntries(adminEntries: Array<FoldInput<AdminRoleValue>>, anchor: GroupAnchor): IsAdminAtHLC;
139
+ export declare function adminAuthorityFromEntries(adminEntries: Array<PositionedFoldInput<AdminRoleValue>>, anchor: GroupAnchor, groupID: string): IsAdminAtPosition;
@@ -1,9 +1,22 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
2
  import { foldAdminRoster } from './admin-roster.js';
3
- /** Ledger entry `type` for a circle-membership claim. */ export const CIRCLE_MEMBER_ENTRY_TYPE = 'circle.member';
4
- function isRemovedValue(value) {
5
- return 'removed' in value && value.removed === true;
3
+ /**
4
+ * Drop the entries whose issuer lacked admin authority at their own slot, keeping
5
+ * the rest in ledger order.
6
+ *
7
+ * INCLUSIVE compare against the ledger prefix through the entry's own position: a
8
+ * grant authorizes from its own slot onward, a demotion stops authorizing from its
9
+ * own slot onward.
10
+ */ export function authorizedEntries(entries, isAdmin) {
11
+ return entries.filter(({ verified, position })=>isAdmin(verified.issuer, position));
6
12
  }
13
+ /**
14
+ * Ledger entry `type` for a circle-membership claim. Every control-ledger type
15
+ * kubun mints lives under `kubun.*` — that namespace is kubun's, and the split
16
+ * between "kubun projects this" and "the MLS library interprets this" is drawn
17
+ * on it. Namespaces the library reserves for itself are its own to name and
18
+ * rename, so kubun stays out of them and never restates them.
19
+ */ export const CIRCLE_MEMBER_ENTRY_TYPE = 'kubun.circle.member';
7
20
  /**
8
21
  * Deterministic composite subject for a circle member. The signed entry's
9
22
  * `subject` encodes both the circle and the member, so the ledger's `subject`
@@ -38,121 +51,36 @@ const MEMBER_SEPARATOR = '/member:';
38
51
  memberDID
39
52
  };
40
53
  }
54
+ /** Ledger entry `type` for a circle definition (create / update / delete). */ export const CIRCLE_DEF_ENTRY_TYPE = 'kubun.circle.def';
41
55
  /**
42
- * Per-member last-writer-wins circle roster. {@link foldLedger} sorts by the
43
- * total order `(hlc, entryID)`, so each subject's highest-ordered entry is
44
- * applied last and decides the outcome: an active value sets the member with its
45
- * role, a tombstone removes them. Whole-value replacement means add / remove /
46
- * re-add converge to the same state on every peer regardless of arrival order.
47
- *
48
- * Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
49
- * HLC so a key authorized when it wrote stays authorized even after a later
50
- * revoke. The gate is the ledger admin-fold ONLY — membership and MLS-removal
51
- * are enforced outside this projection — so the fold remains synchronous and
52
- * deterministic from the ledger alone.
53
- */ export function createCircleMemberReducer(isAdminAtHLC) {
54
- return {
55
- type: CIRCLE_MEMBER_ENTRY_TYPE,
56
- seed: ()=>({
57
- members: new Map()
58
- }),
59
- verifyAuthority: (verified)=>isAdminAtHLC(verified.issuer, verified.entry.hlc),
60
- apply: (verified, state)=>{
61
- const members = new Map(state.members);
62
- const { subject, value } = verified.entry;
63
- if (isRemovedValue(value)) {
64
- members.delete(subject);
65
- return {
66
- members
67
- };
68
- }
69
- const decoded = decodeCircleMemberSubject(subject);
70
- if (decoded == null) {
71
- // Malformed subject: leave the map untouched rather than guess the pair.
72
- return state;
73
- }
74
- members.set(subject, {
75
- circleID: decoded.circleID,
76
- memberDID: decoded.memberDID,
77
- role: value.role
78
- });
79
- return {
80
- members
81
- };
82
- }
83
- };
84
- }
85
- /** Ledger entry `type` for a circle definition (create / update / delete). */ export const CIRCLE_DEF_ENTRY_TYPE = 'circle.def';
86
- function isRemovedDef(value) {
87
- return 'removed' in value && value.removed === true;
88
- }
89
- /**
90
- * Per-circle last-writer-wins definition. The signed entry's `subject` is the
91
- * circleID directly, so no decode is needed. {@link foldLedger} sorts by the
92
- * total order `(hlc, entryID)`, so each circle's highest-ordered entry is
93
- * applied last and decides the outcome: an active value sets the definition, a
94
- * tombstone deletes it. Whole-value replacement means create / update / delete
95
- * converge to the same state on every peer regardless of arrival order.
96
- *
97
- * Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
98
- * HLC. The gate is the ledger admin-fold ONLY — membership and MLS-removal are
99
- * enforced outside this projection — so the fold remains synchronous and
100
- * deterministic from the ledger alone.
101
- */ export function createCircleDefReducer(isAdminAtHLC) {
102
- return {
103
- type: CIRCLE_DEF_ENTRY_TYPE,
104
- seed: ()=>({
105
- circles: new Map()
106
- }),
107
- verifyAuthority: (verified)=>isAdminAtHLC(verified.issuer, verified.entry.hlc),
108
- apply: (verified, state)=>{
109
- const circles = new Map(state.circles);
110
- const { subject, value } = verified.entry;
111
- if (isRemovedDef(value)) {
112
- circles.delete(subject);
113
- return {
114
- circles
115
- };
116
- }
117
- circles.set(subject, {
118
- name: value.name,
119
- description: value.description,
120
- catalogIDs: value.catalogIDs
121
- });
122
- return {
123
- circles
124
- };
125
- }
126
- };
127
- }
128
- /** Ledger entry `type` for a group-settings claim. */ export const GROUP_SETTINGS_ENTRY_TYPE = 'group.settings';
56
+ * Ledger entry `type` for a group-settings claim. Namespaced under `kubun.*`
57
+ * like every kubun control-ledger type a bare `group.settings` sits in the
58
+ * namespace the MLS library reserves, so a commit carrying it is refused whole.
59
+ */ export const GROUP_SETTINGS_ENTRY_TYPE = 'kubun.group.settings';
129
60
  /**
130
61
  * Per-field last-writer-wins group settings. The signed entry's `subject` is the
131
- * groupID directly, so no decode is needed. {@link foldLedger} sorts by the total
132
- * order `(hlc, entryID)` and applies low→high, so for each field the
133
- * highest-ordered entry that SETS that field wins naturally.
62
+ * groupID directly, so no decode is needed. Replayed in the ledger's own
63
+ * enactment order, so for each field the LAST entry that SETS that field wins
64
+ * naturally.
134
65
  *
135
- * This reducer merges per field rather than replacing the whole value the way
136
- * `circle.def` / `circle.member` do: those project a single cohesive record
137
- * where add / remove / re-add must converge to one state, so whole-value
138
- * replacement is correct. Group settings instead accumulate independent fields
139
- * (`name`, `description`) that different entries touch in isolation, so a
140
- * name-only update must not clobber a previously-set description. Presence is
141
- * decided by `value.field !== undefined` rather than `'field' in value`: an
142
- * explicit `undefined` is treated as "not set" (no change), while `''` is a real
143
- * value that clears the field.
66
+ * Merges PER FIELD rather than replacing the whole value the way `circle.def` /
67
+ * `circle.member` do, because these fields are touched in isolation: a name-only
68
+ * update must not clobber a previously-set description. Presence is
69
+ * `value.field !== undefined`, not `'field' in value`, so an explicit `undefined`
70
+ * means no change while `''` or an empty list CLEARS the field.
144
71
  *
145
- * Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
146
- * HLC. The gate is the ledger admin-fold ONLY — membership and MLS-removal are
147
- * enforced outside this projection — so the fold remains synchronous and
148
- * deterministic from the ledger alone.
149
- */ export function createGroupSettingsReducer(isAdminAtHLC) {
72
+ * Unauthorized entries never reach here see {@link authorizedEntries}.
73
+ */ export function createGroupSettingsReducer() {
150
74
  return {
151
75
  type: GROUP_SETTINGS_ENTRY_TYPE,
152
76
  seed: ()=>({
153
77
  settings: new Map()
154
78
  }),
155
- verifyAuthority: (verified)=>isAdminAtHLC(verified.issuer, verified.entry.hlc),
79
+ // Every entry reaching this fold has already passed the admin gate in
80
+ // `authorizedEntries`, which judges an entry against the ledger prefix
81
+ // through its own slot. That question needs the entry's POSITION, which this
82
+ // hook is not given, so the gate cannot live here.
83
+ verifyAuthority: ()=>true,
156
84
  apply: (verified, state)=>{
157
85
  const settings = new Map(state.settings);
158
86
  const { subject, value } = verified.entry;
@@ -166,6 +94,12 @@ function isRemovedDef(value) {
166
94
  if (value.description !== undefined) {
167
95
  next.description = value.description;
168
96
  }
97
+ if (value.openCircleIDs !== undefined) {
98
+ next.openCircleIDs = value.openCircleIDs;
99
+ }
100
+ if (value.commitHub !== undefined) {
101
+ next.commitHub = value.commitHub;
102
+ }
169
103
  settings.set(subject, next);
170
104
  return {
171
105
  settings
@@ -174,25 +108,21 @@ function isRemovedDef(value) {
174
108
  };
175
109
  }
176
110
  /**
177
- * Build an {@link IsAdminAtHLC} predicate from the group's verified admin-role
178
- * ledger entries. It re-folds the admin sub-ledger at each queried entry's HLC,
179
- * so the answer is rotation-aware (was-admin-at-HLC, not is-admin-now): a key
180
- * granted admin at HLC h authorizes entries at or after h, and a later revoke
181
- * stops authorizing entries from that point on. `foldAdminRoster`'s `atHLC`
182
- * compare is inclusive (`entry.hlc <= atHLC`), so an entry at exactly the grant
183
- * HLC sees the grant and is authorized, and an entry at exactly the revoke HLC
184
- * sees the revoke and is not.
111
+ * Build an {@link IsAdminAtPosition} predicate from the group's admin-role ledger
112
+ * entries. It re-folds the admin sub-ledger through each queried slot, so the
113
+ * answer is rotation-aware (was-admin-then, not is-admin-now): a key granted
114
+ * admin at slot n authorizes entries from slot n onward, and a later demotion
115
+ * stops authorizing from its own slot onward. The compare is inclusive
116
+ * (`position <= throughPosition`), which matters only for a role entry judging
117
+ * itself a circle entry never shares a slot with the grant that authorizes it.
185
118
  *
186
- * Callers load the verified admin entries once (via `loadVerifiedAdminEntries`)
187
- * and reuse the returned predicate across the circle / def / settings folds.
119
+ * Callers select the role entries once (via `selectRoleEntries`) and reuse the
120
+ * returned predicate across the circle / def / settings folds.
188
121
  *
189
- * Authority here is ledger-admin-only: it answers "did the ledger grant this
190
- * issuer admin at this HLC?" and does NOT check the issuer's MLS membership at
191
- * that HLC. So an admin who was removed from the MLS group but whose admin role
192
- * was never revoked on the ledger still has authorizing entries folded in.
193
- * Closing that requires removal to write a ledger revocation atomically with the
194
- * MLS-roster delta (the same MLS-commit/roster atomicity work that keeps a
195
- * removed member's writes from applying); it is deliberately out of scope here.
196
- */ export function adminAuthorityFromEntries(adminEntries, anchor) {
197
- return (issuer, hlc)=>foldAdminRoster(adminEntries, anchor, hlc).admins.has(normalizeDID(issuer));
122
+ * LEDGER-ADMIN ONLY it does not check the issuer's MLS membership, so an admin
123
+ * removed from the MLS group whose admin role was never revoked on the ledger still
124
+ * has its entries folded in. Closing that needs removal to write a ledger
125
+ * revocation atomically with the roster delta; out of scope here.
126
+ */ export function adminAuthorityFromEntries(adminEntries, anchor, groupID) {
127
+ return (issuer, position)=>foldAdminRoster(adminEntries, anchor, groupID, position).admins.has(normalizeDID(issuer));
198
128
  }
@@ -0,0 +1,77 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import type { Logger } from '@kubun/logger';
3
+ import type { P2PEventEmitter } from './events.js';
4
+ import type { GroupHandleRegistry } from './group-handle-registry.js';
5
+ import type { SerializedMLSGroupState } from './mls-state.js';
6
+ export type SerializeCommitJournalBlobParams = {
7
+ serializedHandle: SerializedMLSGroupState;
8
+ invitePayload?: string;
9
+ requestID?: string;
10
+ };
11
+ export declare function serializeCommitJournalBlob(params: SerializeCommitJournalBlobParams): Uint8Array;
12
+ /**
13
+ * Read just the control request id a commit journalled, if it carried one.
14
+ *
15
+ * A lost commit the host re-drives (a `ledger` whose tokens survive) is settled
16
+ * under the SAME id it was first enacted for, and that id lives only in the
17
+ * blob. This exposes it without re-implementing the codec: the deserialize stays
18
+ * one function, so the blob shape and its `reviver` are read the same way on
19
+ * every path. An absent id returns `undefined` — never a placeholder, which
20
+ * would flow as a real request and settle an unrelated row.
21
+ */
22
+ export declare function readJournalRequestID(blob: Uint8Array): string | undefined;
23
+ export type AdoptCommitJournalBlobDeps = {
24
+ registry: GroupHandleRegistry;
25
+ groupID: string;
26
+ stores: StoreProvider;
27
+ /**
28
+ * Announces the settle this adopt performs. Optional — without it the request
29
+ * still settles durably, only silently, so a caller reading the row back
30
+ * still sees the outcome.
31
+ */
32
+ emitter?: P2PEventEmitter;
33
+ logger?: Logger;
34
+ };
35
+ /**
36
+ * Adopt a journalled commit now confirmed accepted: restore its post-commit
37
+ * handle, swap it into the registry (persisting the advance), and settle the
38
+ * request it enacted with whatever artifact it produced.
39
+ *
40
+ * An invite's payload is PERSISTED here, never sent. A Welcome reaches its
41
+ * invitee out of band, and by the time a replay runs the caller that would have
42
+ * carried it there is gone — so the only thing this path can do, and the only
43
+ * thing it needs to do, is put the payload where that caller's app can still
44
+ * fetch it by request id.
45
+ *
46
+ * Idempotent, as the journal contract requires — a crash between recording
47
+ * acceptance and clearing the slot replays this. Restoring the serialized handle
48
+ * is a fixed value, `replaceHandle` installs the same advanced state again, and
49
+ * a repeated settle rewrites the same terminal row.
50
+ */
51
+ export declare function adoptCommitJournalBlob(blob: Uint8Array, deps: AdoptCommitJournalBlobDeps): Promise<void>;
52
+ export type SettleLostControlRequestDeps = {
53
+ stores: StoreProvider;
54
+ /**
55
+ * Announces the settle. Optional — without it the request still settles
56
+ * durably, only silently, so a caller reading the row back still sees the
57
+ * outcome.
58
+ */
59
+ emitter?: P2PEventEmitter;
60
+ logger?: Logger;
61
+ };
62
+ /**
63
+ * Settle the control request a lost commit was enacting as `lost`.
64
+ *
65
+ * The mirror image of {@link adoptCommitJournalBlob}: it reads the same blob a
66
+ * pending commit journalled, but a lost commit NEVER landed, so — unlike the
67
+ * accepted path — nothing here restores or advances the handle. The only thing
68
+ * recovered is the request the commit was for, so the caller holding its id
69
+ * learns the action did not happen. For a remove that notice is the admin's
70
+ * sole signal that an eviction they believe took effect did not.
71
+ *
72
+ * A blob with no request id settles NOTHING: an absent id means no request rode
73
+ * this commit, and settling a placeholder id would flow as a real request and
74
+ * mark an unrelated row lost. Idempotent like the adopt path — a repeated
75
+ * settle rewrites the same terminal row.
76
+ */
77
+ export declare function settleLostControlRequest(blob: Uint8Array, deps: SettleLostControlRequestDeps): Promise<void>;
@@ -0,0 +1,108 @@
1
+ import { settleControlRequest } from './control-request.js';
2
+ import { restoreMLSGroupHandle } from './mls-group-handle.js';
3
+ import { replacer, reviver } from './mls-json.js';
4
+ export function serializeCommitJournalBlob(params) {
5
+ const blob = {
6
+ serializedHandle: params.serializedHandle,
7
+ ...params.invitePayload != null ? {
8
+ invitePayload: params.invitePayload
9
+ } : {},
10
+ ...params.requestID != null ? {
11
+ requestID: params.requestID
12
+ } : {}
13
+ };
14
+ return new TextEncoder().encode(JSON.stringify(blob, replacer));
15
+ }
16
+ function deserializeCommitJournalBlob(blob) {
17
+ const json = new TextDecoder().decode(blob);
18
+ return JSON.parse(json, reviver);
19
+ }
20
+ /**
21
+ * Read just the control request id a commit journalled, if it carried one.
22
+ *
23
+ * A lost commit the host re-drives (a `ledger` whose tokens survive) is settled
24
+ * under the SAME id it was first enacted for, and that id lives only in the
25
+ * blob. This exposes it without re-implementing the codec: the deserialize stays
26
+ * one function, so the blob shape and its `reviver` are read the same way on
27
+ * every path. An absent id returns `undefined` — never a placeholder, which
28
+ * would flow as a real request and settle an unrelated row.
29
+ */ export function readJournalRequestID(blob) {
30
+ return deserializeCommitJournalBlob(blob).requestID;
31
+ }
32
+ /**
33
+ * Adopt a journalled commit now confirmed accepted: restore its post-commit
34
+ * handle, swap it into the registry (persisting the advance), and settle the
35
+ * request it enacted with whatever artifact it produced.
36
+ *
37
+ * An invite's payload is PERSISTED here, never sent. A Welcome reaches its
38
+ * invitee out of band, and by the time a replay runs the caller that would have
39
+ * carried it there is gone — so the only thing this path can do, and the only
40
+ * thing it needs to do, is put the payload where that caller's app can still
41
+ * fetch it by request id.
42
+ *
43
+ * Idempotent, as the journal contract requires — a crash between recording
44
+ * acceptance and clearing the slot replays this. Restoring the serialized handle
45
+ * is a fixed value, `replaceHandle` installs the same advanced state again, and
46
+ * a repeated settle rewrites the same terminal row.
47
+ */ export async function adoptCommitJournalBlob(blob, deps) {
48
+ const { serializedHandle, invitePayload, requestID } = deserializeCommitJournalBlob(blob);
49
+ const handle = await restoreMLSGroupHandle({
50
+ serialized: serializedHandle
51
+ });
52
+ await deps.registry.replaceHandle(deps.groupID, handle, {
53
+ stores: deps.stores
54
+ });
55
+ // Settling here is what closes the loop for a request whose process died
56
+ // between publishing the commit and adopting it: the commit landed, so its
57
+ // request landed with it, and the app learns the outcome under the id it has
58
+ // been holding since before the restart. Idempotent like the rest of this
59
+ // function — a repeated adopt rewrites the same terminal row.
60
+ if (requestID != null) {
61
+ await settleControlRequest({
62
+ stores: deps.stores,
63
+ requestID,
64
+ outcome: 'committed',
65
+ ...invitePayload != null ? {
66
+ result: invitePayload
67
+ } : {},
68
+ ...deps.emitter != null ? {
69
+ emitter: deps.emitter
70
+ } : {},
71
+ ...deps.logger != null ? {
72
+ logger: deps.logger
73
+ } : {}
74
+ });
75
+ }
76
+ }
77
+ /**
78
+ * Settle the control request a lost commit was enacting as `lost`.
79
+ *
80
+ * The mirror image of {@link adoptCommitJournalBlob}: it reads the same blob a
81
+ * pending commit journalled, but a lost commit NEVER landed, so — unlike the
82
+ * accepted path — nothing here restores or advances the handle. The only thing
83
+ * recovered is the request the commit was for, so the caller holding its id
84
+ * learns the action did not happen. For a remove that notice is the admin's
85
+ * sole signal that an eviction they believe took effect did not.
86
+ *
87
+ * A blob with no request id settles NOTHING: an absent id means no request rode
88
+ * this commit, and settling a placeholder id would flow as a real request and
89
+ * mark an unrelated row lost. Idempotent like the adopt path — a repeated
90
+ * settle rewrites the same terminal row.
91
+ */ export async function settleLostControlRequest(blob, deps) {
92
+ const { requestID } = deserializeCommitJournalBlob(blob);
93
+ if (requestID == null) {
94
+ deps.logger?.debug('lost commit carried no control request to settle');
95
+ return;
96
+ }
97
+ await settleControlRequest({
98
+ stores: deps.stores,
99
+ requestID,
100
+ outcome: 'lost',
101
+ ...deps.emitter != null ? {
102
+ emitter: deps.emitter
103
+ } : {},
104
+ ...deps.logger != null ? {
105
+ logger: deps.logger
106
+ } : {}
107
+ });
108
+ }