@kubun/plugin-p2p 0.10.1 → 0.11.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 (145) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +3 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +105 -91
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +5 -1
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +61 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +11 -0
  28. package/lib/groups/group-health-monitor.js +139 -1
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +20 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +0 -6
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.js +198 -1
  73. package/lib/hub/wiring.d.ts +17 -48
  74. package/lib/hub/wiring.js +132 -1
  75. package/lib/index.d.ts +17 -16
  76. package/lib/index.js +392 -1
  77. package/lib/protocol.d.ts +1 -1
  78. package/lib/protocol.js +251 -1
  79. package/lib/schema.d.ts +2 -1
  80. package/lib/schema.js +439 -2
  81. package/lib/sync/authorize.d.ts +1 -1
  82. package/lib/sync/authorize.js +73 -1
  83. package/lib/sync/broadcast-queue.js +183 -1
  84. package/lib/sync/broadcast-sender.js +179 -1
  85. package/lib/sync/catalog-scope.js +68 -1
  86. package/lib/sync/errors.d.ts +3 -2
  87. package/lib/sync/errors.js +19 -1
  88. package/lib/sync/forwarder.js +277 -1
  89. package/lib/sync/handlers.js +160 -1
  90. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
  91. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  92. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  93. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  94. package/lib/sync/merkle-apply.d.ts +5 -0
  95. package/lib/sync/merkle-apply.js +194 -1
  96. package/lib/sync/merkle-channel.d.ts +2 -2
  97. package/lib/sync/merkle-channel.js +182 -1
  98. package/lib/sync/merkle-tree.js +120 -1
  99. package/lib/sync/peer-registry.d.ts +1 -1
  100. package/lib/sync/peer-registry.js +107 -1
  101. package/lib/sync/receive-access-gate.js +24 -1
  102. package/lib/sync/scope-resolver.js +41 -1
  103. package/lib/sync/sync-client.d.ts +3 -3
  104. package/lib/sync/sync-client.js +192 -1
  105. package/lib/sync/sync-manager.d.ts +2 -2
  106. package/lib/sync/sync-manager.js +272 -1
  107. package/lib/types.d.ts +11 -0
  108. package/lib/types.js +2 -1
  109. package/lib/util/mutex.js +10 -1
  110. package/lib/utils.js +21 -1
  111. package/package.json +47 -40
  112. package/lib/groups/broadcast-service.d.ts +0 -263
  113. package/lib/groups/broadcast-service.js +0 -1
  114. package/lib/groups/rejoin-codec.d.ts +0 -14
  115. package/lib/groups/rejoin-codec.js +0 -1
  116. package/lib/groups/wire-frame.d.ts +0 -34
  117. package/lib/groups/wire-frame.js +0 -1
  118. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  119. package/lib/hub/circle-catchup-requester.js +0 -1
  120. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  121. package/lib/hub/circle-catchup-responder.js +0 -1
  122. package/lib/hub/connection-pool.d.ts +0 -43
  123. package/lib/hub/connection-pool.js +0 -1
  124. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  125. package/lib/hub/epoch-stale-detector.js +0 -1
  126. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  127. package/lib/hub/forward-remote-broadcast.js +0 -1
  128. package/lib/hub/group-channel.d.ts +0 -89
  129. package/lib/hub/group-channel.js +0 -1
  130. package/lib/hub/hub-connection.d.ts +0 -96
  131. package/lib/hub/hub-connection.js +0 -1
  132. package/lib/hub/receive-handler.d.ts +0 -51
  133. package/lib/hub/receive-handler.js +0 -1
  134. package/lib/hub/rejoin-manager.d.ts +0 -78
  135. package/lib/hub/rejoin-manager.js +0 -1
  136. package/lib/hub/rejoin-responder.d.ts +0 -32
  137. package/lib/hub/rejoin-responder.js +0 -1
  138. package/lib/hub/relay-manager.d.ts +0 -142
  139. package/lib/hub/relay-manager.js +0 -1
  140. package/lib/hub/send-handler.d.ts +0 -40
  141. package/lib/hub/send-handler.js +0 -1
  142. package/lib/hub/tunnel-inbox.d.ts +0 -20
  143. package/lib/hub/tunnel-inbox.js +0 -1
  144. package/lib/hub/wait-for-gate.d.ts +0 -14
  145. package/lib/hub/wait-for-gate.js +0 -1
@@ -0,0 +1,122 @@
1
+ import type { Logger } from '@kubun/logger';
2
+ import type { P2PStoreAPI } from '@kubun/store-p2p';
3
+ import { type CircleDefValue, type CircleMemberValue, type GroupSettingsValue, type IsAdminAtHLC } from './circle-reducers.js';
4
+ import type { GroupAnchor } from './group-anchor.js';
5
+ import { type VerifiedLedgerEntry } from './ledger-entry.js';
6
+ import { type FoldInput } from './ledger-fold.js';
7
+ /**
8
+ * Projection keys, one per entry type — the watermark slot a reproject /
9
+ * incremental advances. Mirrors `ADMIN_ROSTER_PROJECTION`: a single projection
10
+ * per ledger type, so the key is the type itself.
11
+ */
12
+ export declare const CIRCLE_DEF_PROJECTION = "circle.def";
13
+ export declare const CIRCLE_MEMBER_PROJECTION = "circle.member";
14
+ export declare const GROUP_SETTINGS_PROJECTION = "group.settings";
15
+ export declare const loadVerifiedCircleDefEntries: (store: P2PStoreAPI, groupID: string, logger?: Logger) => Promise<FoldInput<CircleDefValue>[]>;
16
+ export declare const loadVerifiedCircleMemberEntries: (store: P2PStoreAPI, groupID: string, logger?: Logger) => Promise<FoldInput<CircleMemberValue>[]>;
17
+ export declare const loadVerifiedGroupSettingsEntries: (store: P2PStoreAPI, groupID: string, logger?: Logger) => Promise<FoldInput<GroupSettingsValue>[]>;
18
+ /**
19
+ * One projected circle row as the fold decides it. `hlc` is the winning active
20
+ * entry's HLC (the row's `hlc` column); `removedAtHLC` is the winning tombstone
21
+ * HLC, or null when the circle is active. A removed circle retains its last
22
+ * active definition so the tombstone row carries real columns — mirroring the
23
+ * LWW store where `markCircleRemoved` only sets `removed_at_hlc` and leaves the
24
+ * name/description intact.
25
+ */
26
+ export type CircleDefProjectionRow = {
27
+ name: string;
28
+ description: string;
29
+ catalogIDs: Array<string>;
30
+ hlc: string;
31
+ removedAtHLC: string | null;
32
+ };
33
+ /**
34
+ * Fold the circle-definition ledger into a per-circle projection covering ALL
35
+ * subjects, active and removed — the active reducer drops removed circles, but a
36
+ * projection must keep a tombstone row for them. Replays in total order under
37
+ * the same admin-authority gate as the reducer: an active entry sets the
38
+ * definition and clears any tombstone (re-add), a tombstone marks the row
39
+ * removed while keeping its last active value. A circle whose only entry is a
40
+ * tombstone (no prior active value) produces no row, matching the LWW store's
41
+ * ignore-if-absent removal.
42
+ */
43
+ export declare function projectCircleDefs(entries: Array<FoldInput<CircleDefValue>>, isAdminAtHLC: IsAdminAtHLC): Map<string, CircleDefProjectionRow>;
44
+ /** One projected circle-member row, keyed by the composite ledger subject. */
45
+ export type CircleMemberProjectionRow = {
46
+ circleID: string;
47
+ memberDID: string;
48
+ role: string;
49
+ hlc: string;
50
+ removedAtHLC: string | null;
51
+ };
52
+ /**
53
+ * Fold the circle-member ledger into a per-subject projection covering ALL
54
+ * subjects, active and removed. Same structure as {@link projectCircleDefs}: an
55
+ * active entry sets the role and clears any tombstone, a tombstone marks the row
56
+ * removed while keeping its last active role. A malformed subject is skipped, as
57
+ * is a tombstone with no prior active value.
58
+ */
59
+ export declare function projectCircleMembers(entries: Array<FoldInput<CircleMemberValue>>, isAdminAtHLC: IsAdminAtHLC): Map<string, CircleMemberProjectionRow>;
60
+ /**
61
+ * Authoritatively rebuild a circle/settings projection from the whole ledger and
62
+ * reset the watermark to the newest folded entry. Used on a first projection, an
63
+ * out-of-order arrival, or drift repair — anywhere the cached projection can no
64
+ * longer be trusted as a fold-through-watermark. Authority is the admin fold
65
+ * (`adminAuthorityFromEntries`), so every projection honors the same admin gate
66
+ * the broadcast path enforces. Each `reconcile*` write is unconditional, so the
67
+ * row converges to exactly the fold's decision regardless of prior contents.
68
+ */
69
+ export type ReprojectCircleParams = {
70
+ store: P2PStoreAPI;
71
+ groupID: string;
72
+ anchor: GroupAnchor;
73
+ logger?: Logger;
74
+ };
75
+ export declare function reprojectCircleDefs(params: ReprojectCircleParams): Promise<void>;
76
+ export declare function reprojectCircleMembers(params: ReprojectCircleParams): Promise<void>;
77
+ /**
78
+ * Reproject group settings onto the group row. Settings are PARTIAL per-field
79
+ * (no tombstone), so the committed reducer's active map IS the full projection:
80
+ * fold it under the admin gate, then write the merged fields via the
81
+ * unconditional `updateGroup`. A field no entry ever set stays at the group's
82
+ * original value (creation-time name/description).
83
+ */
84
+ export declare function reprojectGroupSettings(params: ReprojectCircleParams): Promise<void>;
85
+ /**
86
+ * Apply one strictly-newer-than-watermark entry onto the current projection
87
+ * without re-folding the whole ledger. The caller guarantees the entry is
88
+ * in-order, so the projection already equals the fold-so-far. Authority is the
89
+ * admin fold at the entry's own HLC: a non-admin issuer is dropped (never
90
+ * thrown). Unlike the admin roster's incremental applier — whose authority reads
91
+ * the projection itself — circle authority lives in the separate admin
92
+ * sub-ledger, so the admin entries are loaded here.
93
+ *
94
+ * The watermark is advanced by the caller (the ingest handler), not here —
95
+ * mirroring the admin roster's split of responsibility — so the projection
96
+ * mutation and the watermark advance stay in the handler's single transaction
97
+ * even when the entry is dropped (it is still durable and accounted for).
98
+ */
99
+ export type ApplyCircleDefEntryIncrementalParams = {
100
+ store: P2PStoreAPI;
101
+ groupID: string;
102
+ anchor: GroupAnchor;
103
+ verified: VerifiedLedgerEntry<CircleDefValue>;
104
+ logger?: Logger;
105
+ };
106
+ export declare function applyCircleDefEntryIncremental(params: ApplyCircleDefEntryIncrementalParams): Promise<void>;
107
+ export type ApplyCircleMemberEntryIncrementalParams = {
108
+ store: P2PStoreAPI;
109
+ groupID: string;
110
+ anchor: GroupAnchor;
111
+ verified: VerifiedLedgerEntry<CircleMemberValue>;
112
+ logger?: Logger;
113
+ };
114
+ export declare function applyCircleMemberEntryIncremental(params: ApplyCircleMemberEntryIncrementalParams): Promise<void>;
115
+ export type ApplyGroupSettingsEntryIncrementalParams = {
116
+ store: P2PStoreAPI;
117
+ groupID: string;
118
+ anchor: GroupAnchor;
119
+ verified: VerifiedLedgerEntry<GroupSettingsValue>;
120
+ logger?: Logger;
121
+ };
122
+ export declare function applyGroupSettingsEntryIncremental(params: ApplyGroupSettingsEntryIncrementalParams): Promise<void>;
@@ -0,0 +1,288 @@
1
+ import { loadVerifiedAdminEntries } from './admin-roster.js';
2
+ import { adminAuthorityFromEntries, CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, createGroupSettingsReducer, decodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
3
+ import { verifyLedgerEntry } from './ledger-entry.js';
4
+ import { foldLedger } from './ledger-fold.js';
5
+ /**
6
+ * Projection keys, one per entry type — the watermark slot a reproject /
7
+ * incremental advances. Mirrors `ADMIN_ROSTER_PROJECTION`: a single projection
8
+ * per ledger type, so the key is the type itself.
9
+ */ export const CIRCLE_DEF_PROJECTION = CIRCLE_DEF_ENTRY_TYPE;
10
+ export const CIRCLE_MEMBER_PROJECTION = CIRCLE_MEMBER_ENTRY_TYPE;
11
+ export const GROUP_SETTINGS_PROJECTION = GROUP_SETTINGS_ENTRY_TYPE;
12
+ function isRemovedDef(value) {
13
+ return 'removed' in value && value.removed === true;
14
+ }
15
+ function isRemovedMember(value) {
16
+ return 'removed' in value && value.removed === true;
17
+ }
18
+ /** Total order over the ledger: HLC first, content-addressed id breaks ties. */ function byTotalOrder(a, b) {
19
+ if (a.verified.entry.hlc !== b.verified.entry.hlc) {
20
+ return a.verified.entry.hlc < b.verified.entry.hlc ? -1 : 1;
21
+ }
22
+ if (a.entryID === b.entryID) return 0;
23
+ return a.entryID < b.entryID ? -1 : 1;
24
+ }
25
+ function maxHLC(entries) {
26
+ let max = null;
27
+ for (const { verified } of entries){
28
+ if (max == null || verified.entry.hlc > max) max = verified.entry.hlc;
29
+ }
30
+ return max;
31
+ }
32
+ // --- Per-type verified-entry loaders ---
33
+ /**
34
+ * Build a verified-entry loader for one ledger type, memoized per group on the
35
+ * stored id set. The stored token is re-verified rather than trusting the
36
+ * decoded `value` column — the ledger is the source of truth and must stay
37
+ * self-verifying, so a token that no longer verifies is dropped and the rest
38
+ * survive. The cache key is derived purely from content-addressed entry ids, so
39
+ * a forged or altered entry yields a different id set, a different key, and a
40
+ * full re-verification; one slot per group self-invalidates on every append.
41
+ * One cache instance per type keeps the slots isolated across ledger types.
42
+ */ function createVerifiedEntryLoader(entryType) {
43
+ const cache = new Map();
44
+ return async (store, groupID, logger)=>{
45
+ const rows = await store.getLedgerEntriesByType(groupID, entryType);
46
+ const key = rows.map((row)=>row.entry_id).join('\n');
47
+ const cached = cache.get(groupID);
48
+ if (cached != null && cached.key === key) {
49
+ return cached.entries;
50
+ }
51
+ const entries = [];
52
+ for (const row of rows){
53
+ const verified = await verifyLedgerEntry(row.signed_token);
54
+ if (verified == null) {
55
+ logger?.warn('circle projection: dropped stored entry whose token no longer verifies', {
56
+ groupID,
57
+ entryType,
58
+ entryID: row.entry_id
59
+ });
60
+ continue;
61
+ }
62
+ entries.push({
63
+ verified,
64
+ entryID: row.entry_id
65
+ });
66
+ }
67
+ cache.set(groupID, {
68
+ key,
69
+ entries
70
+ });
71
+ return entries;
72
+ };
73
+ }
74
+ export const loadVerifiedCircleDefEntries = createVerifiedEntryLoader(CIRCLE_DEF_ENTRY_TYPE);
75
+ export const loadVerifiedCircleMemberEntries = createVerifiedEntryLoader(CIRCLE_MEMBER_ENTRY_TYPE);
76
+ export const loadVerifiedGroupSettingsEntries = createVerifiedEntryLoader(GROUP_SETTINGS_ENTRY_TYPE);
77
+ /**
78
+ * Fold the circle-definition ledger into a per-circle projection covering ALL
79
+ * subjects, active and removed — the active reducer drops removed circles, but a
80
+ * projection must keep a tombstone row for them. Replays in total order under
81
+ * the same admin-authority gate as the reducer: an active entry sets the
82
+ * definition and clears any tombstone (re-add), a tombstone marks the row
83
+ * removed while keeping its last active value. A circle whose only entry is a
84
+ * tombstone (no prior active value) produces no row, matching the LWW store's
85
+ * ignore-if-absent removal.
86
+ */ export function projectCircleDefs(entries, isAdminAtHLC) {
87
+ const rows = new Map();
88
+ for (const { verified } of [
89
+ ...entries
90
+ ].sort(byTotalOrder)){
91
+ if (!isAdminAtHLC(verified.issuer, verified.entry.hlc)) continue;
92
+ const { subject, value, hlc } = verified.entry;
93
+ if (isRemovedDef(value)) {
94
+ const existing = rows.get(subject);
95
+ if (existing == null) continue;
96
+ rows.set(subject, {
97
+ ...existing,
98
+ removedAtHLC: hlc
99
+ });
100
+ } else {
101
+ rows.set(subject, {
102
+ name: value.name,
103
+ description: value.description,
104
+ catalogIDs: value.catalogIDs,
105
+ hlc,
106
+ removedAtHLC: null
107
+ });
108
+ }
109
+ }
110
+ return rows;
111
+ }
112
+ /**
113
+ * Fold the circle-member ledger into a per-subject projection covering ALL
114
+ * subjects, active and removed. Same structure as {@link projectCircleDefs}: an
115
+ * active entry sets the role and clears any tombstone, a tombstone marks the row
116
+ * removed while keeping its last active role. A malformed subject is skipped, as
117
+ * is a tombstone with no prior active value.
118
+ */ export function projectCircleMembers(entries, isAdminAtHLC) {
119
+ const rows = new Map();
120
+ for (const { verified } of [
121
+ ...entries
122
+ ].sort(byTotalOrder)){
123
+ if (!isAdminAtHLC(verified.issuer, verified.entry.hlc)) continue;
124
+ const { subject, value, hlc } = verified.entry;
125
+ if (isRemovedMember(value)) {
126
+ const existing = rows.get(subject);
127
+ if (existing == null) continue;
128
+ rows.set(subject, {
129
+ ...existing,
130
+ removedAtHLC: hlc
131
+ });
132
+ } else {
133
+ const decoded = decodeCircleMemberSubject(subject);
134
+ if (decoded == null) continue;
135
+ rows.set(subject, {
136
+ circleID: decoded.circleID,
137
+ memberDID: decoded.memberDID,
138
+ role: value.role,
139
+ hlc,
140
+ removedAtHLC: null
141
+ });
142
+ }
143
+ }
144
+ return rows;
145
+ }
146
+ async function adminAuthorityFor(params) {
147
+ const adminEntries = await loadVerifiedAdminEntries(params.store, params.groupID, params.logger);
148
+ return adminAuthorityFromEntries(adminEntries, params.anchor);
149
+ }
150
+ export async function reprojectCircleDefs(params) {
151
+ const { store, groupID } = params;
152
+ const [isAdmin, entries] = await Promise.all([
153
+ adminAuthorityFor(params),
154
+ loadVerifiedCircleDefEntries(store, groupID, params.logger)
155
+ ]);
156
+ const rows = projectCircleDefs(entries, isAdmin);
157
+ for (const [circleID, row] of rows){
158
+ await store.reconcileCircleProjection({
159
+ id: circleID,
160
+ groupID,
161
+ name: row.name,
162
+ description: row.description,
163
+ catalogIDs: row.catalogIDs,
164
+ hlc: row.hlc,
165
+ removedAtHLC: row.removedAtHLC
166
+ });
167
+ }
168
+ const max = maxHLC(entries);
169
+ if (max != null) {
170
+ await store.setProjectionWatermark(groupID, CIRCLE_DEF_PROJECTION, max);
171
+ }
172
+ }
173
+ export async function reprojectCircleMembers(params) {
174
+ const { store, groupID } = params;
175
+ const [isAdmin, entries] = await Promise.all([
176
+ adminAuthorityFor(params),
177
+ loadVerifiedCircleMemberEntries(store, groupID, params.logger)
178
+ ]);
179
+ const rows = projectCircleMembers(entries, isAdmin);
180
+ for (const row of rows.values()){
181
+ await store.reconcileCircleMemberProjection({
182
+ circleID: row.circleID,
183
+ memberDID: row.memberDID,
184
+ role: row.role,
185
+ hlc: row.hlc,
186
+ removedAtHLC: row.removedAtHLC
187
+ });
188
+ }
189
+ const max = maxHLC(entries);
190
+ if (max != null) {
191
+ await store.setProjectionWatermark(groupID, CIRCLE_MEMBER_PROJECTION, max);
192
+ }
193
+ }
194
+ /**
195
+ * Reproject group settings onto the group row. Settings are PARTIAL per-field
196
+ * (no tombstone), so the committed reducer's active map IS the full projection:
197
+ * fold it under the admin gate, then write the merged fields via the
198
+ * unconditional `updateGroup`. A field no entry ever set stays at the group's
199
+ * original value (creation-time name/description).
200
+ */ export async function reprojectGroupSettings(params) {
201
+ const { store, groupID, anchor } = params;
202
+ const [isAdmin, entries] = await Promise.all([
203
+ adminAuthorityFor(params),
204
+ loadVerifiedGroupSettingsEntries(store, groupID, params.logger)
205
+ ]);
206
+ const { settings } = foldLedger(entries, anchor, createGroupSettingsReducer(isAdmin));
207
+ const merged = settings.get(groupID);
208
+ if (merged != null) {
209
+ await store.updateGroup(groupID, merged);
210
+ }
211
+ const max = maxHLC(entries);
212
+ if (max != null) {
213
+ await store.setProjectionWatermark(groupID, GROUP_SETTINGS_PROJECTION, max);
214
+ }
215
+ }
216
+ export async function applyCircleDefEntryIncremental(params) {
217
+ const { store, groupID, anchor, verified, logger } = params;
218
+ const adminEntries = await loadVerifiedAdminEntries(store, groupID, logger);
219
+ const isAdmin = adminAuthorityFromEntries(adminEntries, anchor);
220
+ const { subject, value, hlc } = verified.entry;
221
+ if (!isAdmin(verified.issuer, hlc)) {
222
+ logger?.warn('circle projection: dropped incremental circle.def from a non-admin issuer', {
223
+ groupID,
224
+ issuer: verified.issuer
225
+ });
226
+ } else if (isRemovedDef(value)) {
227
+ // Tombstone an existing active row, leaving its name/description intact —
228
+ // the LWW removal write, correct for an in-order entry on the active row.
229
+ await store.markCircleRemoved(subject, hlc);
230
+ } else {
231
+ await store.reconcileCircleProjection({
232
+ id: subject,
233
+ groupID,
234
+ name: value.name,
235
+ description: value.description,
236
+ catalogIDs: value.catalogIDs,
237
+ hlc,
238
+ removedAtHLC: null
239
+ });
240
+ }
241
+ }
242
+ export async function applyCircleMemberEntryIncremental(params) {
243
+ const { store, groupID, anchor, verified, logger } = params;
244
+ const adminEntries = await loadVerifiedAdminEntries(store, groupID, logger);
245
+ const isAdmin = adminAuthorityFromEntries(adminEntries, anchor);
246
+ const { subject, value, hlc } = verified.entry;
247
+ if (!isAdmin(verified.issuer, hlc)) {
248
+ logger?.warn('circle projection: dropped incremental circle.member from a non-admin issuer', {
249
+ groupID,
250
+ issuer: verified.issuer
251
+ });
252
+ } else if (isRemovedMember(value)) {
253
+ const decoded = decodeCircleMemberSubject(subject);
254
+ if (decoded != null) {
255
+ await store.removeCircleMember(decoded.circleID, decoded.memberDID, hlc);
256
+ }
257
+ } else {
258
+ const decoded = decodeCircleMemberSubject(subject);
259
+ if (decoded != null) {
260
+ await store.reconcileCircleMemberProjection({
261
+ circleID: decoded.circleID,
262
+ memberDID: decoded.memberDID,
263
+ role: value.role,
264
+ hlc,
265
+ removedAtHLC: null
266
+ });
267
+ }
268
+ }
269
+ }
270
+ export async function applyGroupSettingsEntryIncremental(params) {
271
+ const { store, groupID, anchor, verified, logger } = params;
272
+ const adminEntries = await loadVerifiedAdminEntries(store, groupID, logger);
273
+ const isAdmin = adminAuthorityFromEntries(adminEntries, anchor);
274
+ const { value, hlc } = verified.entry;
275
+ if (!isAdmin(verified.issuer, hlc)) {
276
+ logger?.warn('circle projection: dropped incremental group.settings from a non-admin issuer', {
277
+ groupID,
278
+ issuer: verified.issuer
279
+ });
280
+ } else {
281
+ // Per-field merge onto the group row: `updateGroup` sets only the fields the
282
+ // entry carries (an explicit `''` clears; an omitted field is left alone).
283
+ await store.updateGroup(groupID, {
284
+ name: value.name,
285
+ description: value.description
286
+ });
287
+ }
288
+ }
@@ -0,0 +1,178 @@
1
+ import { type AdminRoleValue } from './admin-roster.js';
2
+ import type { GroupAnchor } from './group-anchor.js';
3
+ import type { FoldInput, LedgerReducer } from './ledger-fold.js';
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.
12
+ */
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";
16
+ /**
17
+ * The claim carried by a circle-member entry. The active form names the
18
+ * 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
20
+ * state outright, so the value alone decides active-with-role vs removed. `role`
21
+ * mirrors the store's free-form role column (no fixed enum), so it stays a
22
+ * string rather than inventing a circle-specific role type.
23
+ */
24
+ export type CircleMemberValue = {
25
+ role: string;
26
+ } | {
27
+ removed: true;
28
+ };
29
+ /**
30
+ * Deterministic composite subject for a circle member. The signed entry's
31
+ * `subject` encodes both the circle and the member, so the ledger's `subject`
32
+ * column uniquely identifies the pair and per-member LWW ordering + dedup work
33
+ * without consulting the value.
34
+ *
35
+ * Unambiguous because the member is a normalized DID (`did:method:...`), which
36
+ * never contains the `/member:` separator: every reachable separator therefore
37
+ * sits between the circle id and the DID, and the decoder splits on the last
38
+ * one. The labeled `circle:` / `/member:` prefixes also keep the encoding
39
+ * distinct from any other reducer's subject scheme.
40
+ */
41
+ export declare function encodeCircleMemberSubject(circleID: string, memberDID: string): string;
42
+ /** Tolerant inverse of {@link encodeCircleMemberSubject}: null on a malformed subject. */
43
+ export declare function decodeCircleMemberSubject(subject: string): {
44
+ circleID: string;
45
+ memberDID: string;
46
+ } | 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
+ /** Ledger entry `type` for a circle definition (create / update / delete). */
74
+ export declare const CIRCLE_DEF_ENTRY_TYPE = "circle.def";
75
+ /**
76
+ * The claim carried by a circle-definition entry. The active form is the whole
77
+ * circle definition; the tombstone form deletes it. Both are whole-value: the
78
+ * highest-ordered entry for a circle replaces the projected definition outright,
79
+ * so create and update share one shape and the value alone decides present vs
80
+ * deleted.
81
+ */
82
+ export type CircleDefValue = {
83
+ name: string;
84
+ description: string;
85
+ catalogIDs: Array<string>;
86
+ } | {
87
+ removed: true;
88
+ };
89
+ /**
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.
92
+ */
93
+ export type CircleDefState = {
94
+ circles: Map<string, {
95
+ name: string;
96
+ description: string;
97
+ catalogIDs: Array<string>;
98
+ }>;
99
+ };
100
+ /**
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.
107
+ *
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.
123
+ */
124
+ export type GroupSettingsValue = {
125
+ name?: string;
126
+ description?: string;
127
+ };
128
+ /** Folded group settings, keyed by groupID (the entry subject). */
129
+ export type GroupSettingsState = {
130
+ settings: Map<string, {
131
+ name?: string;
132
+ description?: string;
133
+ }>;
134
+ };
135
+ /**
136
+ * 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.
140
+ *
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.
150
+ *
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.
155
+ */
156
+ export declare function createGroupSettingsReducer(isAdminAtHLC: IsAdminAtHLC): LedgerReducer<GroupSettingsValue, GroupSettingsState>;
157
+ /**
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.
166
+ *
167
+ * Callers load the verified admin entries once (via `loadVerifiedAdminEntries`)
168
+ * and reuse the returned predicate across the circle / def / settings folds.
169
+ *
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.
177
+ */
178
+ export declare function adminAuthorityFromEntries(adminEntries: Array<FoldInput<AdminRoleValue>>, anchor: GroupAnchor): IsAdminAtHLC;