@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
@@ -0,0 +1,50 @@
1
+ import { type OwnIdentity } from '@kokuin/token';
2
+ import type { CatalogRecord } from '@kubun/protocol';
3
+ type SetPayload = {
4
+ op: 'set';
5
+ catalog: CatalogRecord;
6
+ };
7
+ type UpdatePayload = {
8
+ op: 'update';
9
+ catalogID: string;
10
+ update: {
11
+ name?: string;
12
+ description?: string;
13
+ filterCriteria?: CatalogRecord['filterCriteria'];
14
+ };
15
+ hlc: string;
16
+ };
17
+ type RemovePayload = {
18
+ op: 'remove';
19
+ catalogID: string;
20
+ hlc: string;
21
+ };
22
+ /**
23
+ * The verified claim behind a catalog control broadcast. `issuer` is the
24
+ * authenticated author (the verified token `iss`). Every authoritative field
25
+ * comes from the signed payload, so a receiver never trusts the surrounding
26
+ * plaintext frame. Sender-bound: the receiver applies a set only when the
27
+ * catalog's `ownerDID` equals this issuer, and an update/remove only when the
28
+ * locally-stored catalog is owned by this issuer.
29
+ */
30
+ export type VerifiedCatalog = {
31
+ issuer: string;
32
+ claim: SetPayload;
33
+ } | {
34
+ issuer: string;
35
+ claim: UpdatePayload;
36
+ } | {
37
+ issuer: string;
38
+ claim: RemovePayload;
39
+ };
40
+ export declare function signCatalogSet(identity: OwnIdentity, catalog: CatalogRecord): Promise<string>;
41
+ export declare function signCatalogUpdate(identity: OwnIdentity, claim: Omit<UpdatePayload, 'op'>): Promise<string>;
42
+ export declare function signCatalogRemove(identity: OwnIdentity, claim: Omit<RemovePayload, 'op'>): Promise<string>;
43
+ /**
44
+ * Verify a signed catalog token and extract its claim. Returns `null` (never
45
+ * throws) when the token is unparseable, unsigned (`alg: 'none'`), or
46
+ * structurally malformed — an attacker cannot forge an `iss` this way. The
47
+ * issuer is the normalized verified `iss`.
48
+ */
49
+ export declare function verifyCatalog(token: string): Promise<VerifiedCatalog | null>;
50
+ export {};
@@ -0,0 +1,96 @@
1
+ import { isVerifiedToken, normalizeDID, stringifyToken, verifyToken } from '@kokuin/token';
2
+ export async function signCatalogSet(identity, catalog) {
3
+ const signed = await identity.signToken({
4
+ op: 'set',
5
+ catalog
6
+ }, {
7
+ embedLongForm: true
8
+ });
9
+ return stringifyToken(signed);
10
+ }
11
+ export async function signCatalogUpdate(identity, claim) {
12
+ const signed = await identity.signToken({
13
+ op: 'update',
14
+ ...claim
15
+ }, {
16
+ embedLongForm: true
17
+ });
18
+ return stringifyToken(signed);
19
+ }
20
+ export async function signCatalogRemove(identity, claim) {
21
+ const signed = await identity.signToken({
22
+ op: 'remove',
23
+ ...claim
24
+ }, {
25
+ embedLongForm: true
26
+ });
27
+ return stringifyToken(signed);
28
+ }
29
+ function isCatalogRecord(value) {
30
+ if (value == null || typeof value !== 'object') {
31
+ return false;
32
+ }
33
+ const record = value;
34
+ return typeof record.id === 'string' && typeof record.ownerDID === 'string' && typeof record.name === 'string' && typeof record.hlc === 'string';
35
+ }
36
+ /**
37
+ * Verify a signed catalog token and extract its claim. Returns `null` (never
38
+ * throws) when the token is unparseable, unsigned (`alg: 'none'`), or
39
+ * structurally malformed — an attacker cannot forge an `iss` this way. The
40
+ * issuer is the normalized verified `iss`.
41
+ */ export async function verifyCatalog(token) {
42
+ let verified;
43
+ try {
44
+ verified = await verifyToken(token);
45
+ } catch {
46
+ return null;
47
+ }
48
+ if (!isVerifiedToken(verified)) {
49
+ return null;
50
+ }
51
+ const { iss, op } = verified.payload;
52
+ const issuer = normalizeDID(iss);
53
+ if (op === 'set') {
54
+ const { catalog } = verified.payload;
55
+ if (!isCatalogRecord(catalog)) {
56
+ return null;
57
+ }
58
+ return {
59
+ issuer,
60
+ claim: {
61
+ op: 'set',
62
+ catalog
63
+ }
64
+ };
65
+ }
66
+ if (op === 'update') {
67
+ const { catalogID, update, hlc } = verified.payload;
68
+ if (typeof catalogID !== 'string' || typeof hlc !== 'string' || update == null) {
69
+ return null;
70
+ }
71
+ return {
72
+ issuer,
73
+ claim: {
74
+ op: 'update',
75
+ catalogID,
76
+ update: update,
77
+ hlc
78
+ }
79
+ };
80
+ }
81
+ if (op === 'remove') {
82
+ const { catalogID, hlc } = verified.payload;
83
+ if (typeof catalogID !== 'string' || typeof hlc !== 'string') {
84
+ return null;
85
+ }
86
+ return {
87
+ issuer,
88
+ claim: {
89
+ op: 'remove',
90
+ catalogID,
91
+ hlc
92
+ }
93
+ };
94
+ }
95
+ return null;
96
+ }
@@ -1,20 +1,9 @@
1
1
  import type { Logger } from '@kubun/logger';
2
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>[]>;
3
+ import type { GroupAnchor, LedgerLogEntry } from '@kumiai/mls';
4
+ import { type CircleDefValue, type CircleMemberValue, type GroupSettingsValue, type IsAdminAtPosition } from './circle-reducers.js';
5
+ import { type PositionedFoldInput } from './ledger.js';
6
+ export declare const selectCircleMemberEntries: (ledger: ReadonlyArray<LedgerLogEntry>) => PositionedFoldInput<CircleMemberValue>[];
18
7
  /**
19
8
  * One projected circle row as the fold decides it. `hlc` is the winning active
20
9
  * entry's HLC (the row's `hlc` column); `removedAtHLC` is the winning tombstone
@@ -33,14 +22,14 @@ export type CircleDefProjectionRow = {
33
22
  /**
34
23
  * Fold the circle-definition ledger into a per-circle projection covering ALL
35
24
  * 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
25
+ * projection must keep a tombstone row for them. Replays in the ledger's own
26
+ * enactment order under the same admin-authority gate as the reducer: an active entry sets the
38
27
  * definition and clears any tombstone (re-add), a tombstone marks the row
39
28
  * removed while keeping its last active value. A circle whose only entry is a
40
29
  * tombstone (no prior active value) produces no row, matching the LWW store's
41
30
  * ignore-if-absent removal.
42
31
  */
43
- export declare function projectCircleDefs(entries: Array<FoldInput<CircleDefValue>>, isAdminAtHLC: IsAdminAtHLC): Map<string, CircleDefProjectionRow>;
32
+ export declare function projectCircleDefs(entries: Array<PositionedFoldInput<CircleDefValue>>, isAdmin: IsAdminAtPosition): Map<string, CircleDefProjectionRow>;
44
33
  /** One projected circle-member row, keyed by the composite ledger subject. */
45
34
  export type CircleMemberProjectionRow = {
46
35
  circleID: string;
@@ -54,22 +43,31 @@ export type CircleMemberProjectionRow = {
54
43
  * subjects, active and removed. Same structure as {@link projectCircleDefs}: an
55
44
  * active entry sets the role and clears any tombstone, a tombstone marks the row
56
45
  * removed while keeping its last active role. A malformed subject is skipped, as
57
- * is a tombstone with no prior active value.
46
+ * is a tombstone with no prior active value. Gates each entry on the same
47
+ * admin-authority check as the reducer's fold — an entry from a non-admin issuer
48
+ * never projects.
58
49
  */
59
- export declare function projectCircleMembers(entries: Array<FoldInput<CircleMemberValue>>, isAdminAtHLC: IsAdminAtHLC): Map<string, CircleMemberProjectionRow>;
50
+ export declare function projectCircleMembers(entries: Array<PositionedFoldInput<CircleMemberValue>>, isAdmin: IsAdminAtPosition): Map<string, CircleMemberProjectionRow>;
60
51
  /**
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.
52
+ * Rebuild a circle/settings projection from the whole ledger.
53
+ *
54
+ * The only projection path there is: an entry is judged against the ledger prefix
55
+ * through its own slot, so folding the whole log is the same answer as folding it
56
+ * incrementally, and there is no cached-projection-is-stale case to detect.
57
+ * Authority is the admin fold (`adminAuthorityFromEntries`), so every projection
58
+ * honors one gate. Each `reconcile*` write is unconditional, so the row converges
59
+ * to exactly the fold's decision regardless of prior contents.
68
60
  */
69
61
  export type ReprojectCircleParams = {
70
62
  store: P2PStoreAPI;
71
63
  groupID: string;
72
64
  anchor: GroupAnchor;
65
+ /**
66
+ * The group's control ledger, in the handle's enactment order. Passed in rather
67
+ * than read: these projections run where the handle is already open, and every
68
+ * entry they fold is one the handle verified on the way in.
69
+ */
70
+ ledger: ReadonlyArray<LedgerLogEntry>;
73
71
  logger?: Logger;
74
72
  };
75
73
  export declare function reprojectCircleDefs(params: ReprojectCircleParams): Promise<void>;
@@ -81,42 +79,12 @@ export declare function reprojectCircleMembers(params: ReprojectCircleParams): P
81
79
  * unconditional `updateGroup`. A field no entry ever set stays at the group's
82
80
  * original value (creation-time name/description).
83
81
  */
84
- export declare function reprojectGroupSettings(params: ReprojectCircleParams): Promise<void>;
85
82
  /**
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).
83
+ * Fold the group's settings ledger under the admin gate and return the merged
84
+ * per-field value, or `undefined` when no authorized settings entry has been
85
+ * folded. This is the same fold {@link reprojectGroupSettings} materializes,
86
+ * exposed so a producer can read the CURRENT folded designation before authoring
87
+ * what a device projects and what it checks before writing cannot then drift.
98
88
  */
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>;
89
+ export declare function foldGroupSettings(ledger: ReadonlyArray<LedgerLogEntry>, groupID: string, anchor: GroupAnchor): GroupSettingsValue | undefined;
90
+ export declare function reprojectGroupSettings(params: ReprojectCircleParams): Promise<void>;
@@ -1,95 +1,63 @@
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;
1
+ import { selectRoleEntries } from './admin-roster.js';
2
+ import { adminAuthorityFromEntries, authorizedEntries, CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, createGroupSettingsReducer, decodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
3
+ import { entryOrd, foldLedgerEntries } from './ledger.js';
12
4
  function isRemovedDef(value) {
13
5
  return 'removed' in value && value.removed === true;
14
6
  }
15
7
  function isRemovedMember(value) {
16
8
  return 'removed' in value && value.removed === true;
17
9
  }
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
10
  /**
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
- }
11
+ * Build a selector for one ledger type, carrying each entry's slot in the WHOLE
12
+ * ledger so its authority can be judged against the prefix that precedes it.
13
+ *
14
+ * Entries come from the handle's own log, and every door into it verifies —
15
+ * `GroupHandle.applyLedgerEntries`, `bootstrapLedger` and an accepted commit
16
+ * each re-verify the tokens they admit so these are pure filters over an
17
+ * in-memory array: no re-verification, and nothing to memoize. They replaced
18
+ * loaders that re-verified every stored `signed_token` on each call, which was
19
+ * needed only because the SQL row they read could not vouch for itself.
20
+ *
21
+ * Selecting by `type` is what licenses the cast: the log is typed over an unknown
22
+ * claim value, and the type is the claim about what that value is.
23
+ */ function createEntrySelector(entryType) {
24
+ return (ledger)=>{
51
25
  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
- });
26
+ for (const [position, held] of ledger.entries()){
27
+ if (held.verified.entry.type !== entryType) {
60
28
  continue;
61
29
  }
62
30
  entries.push({
63
- verified,
64
- entryID: row.entry_id
31
+ verified: held.verified,
32
+ entryID: held.entryID,
33
+ position
65
34
  });
66
35
  }
67
- cache.set(groupID, {
68
- key,
69
- entries
70
- });
71
36
  return entries;
72
37
  };
73
38
  }
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);
39
+ const selectCircleDefEntries = createEntrySelector(CIRCLE_DEF_ENTRY_TYPE);
40
+ export const selectCircleMemberEntries = createEntrySelector(CIRCLE_MEMBER_ENTRY_TYPE);
41
+ const selectGroupSettingsEntries = createEntrySelector(GROUP_SETTINGS_ENTRY_TYPE);
77
42
  /**
78
43
  * Fold the circle-definition ledger into a per-circle projection covering ALL
79
44
  * 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
45
+ * projection must keep a tombstone row for them. Replays in the ledger's own
46
+ * enactment order under the same admin-authority gate as the reducer: an active entry sets the
82
47
  * definition and clears any tombstone (re-add), a tombstone marks the row
83
48
  * removed while keeping its last active value. A circle whose only entry is a
84
49
  * tombstone (no prior active value) produces no row, matching the LWW store's
85
50
  * ignore-if-absent removal.
86
- */ export function projectCircleDefs(entries, isAdminAtHLC) {
51
+ */ export function projectCircleDefs(entries, isAdmin) {
87
52
  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;
53
+ for (const { verified } of authorizedEntries(entries, isAdmin)){
54
+ const { subject, value } = verified.entry;
55
+ const hlc = entryOrd(verified.entry);
56
+ // Kubun stamps `ord` on every circle entry it signs; one without is foreign
57
+ // or malformed. Skipping is fail-closed projecting it would have to invent
58
+ // an HLC, and on a tombstone a `null` `removed_at_hlc` reads as ACTIVE and
59
+ // silently revives the row.
60
+ if (hlc == null) continue;
93
61
  if (isRemovedDef(value)) {
94
62
  const existing = rows.get(subject);
95
63
  if (existing == null) continue;
@@ -114,14 +82,17 @@ export const loadVerifiedGroupSettingsEntries = createVerifiedEntryLoader(GROUP_
114
82
  * subjects, active and removed. Same structure as {@link projectCircleDefs}: an
115
83
  * active entry sets the role and clears any tombstone, a tombstone marks the row
116
84
  * 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) {
85
+ * is a tombstone with no prior active value. Gates each entry on the same
86
+ * admin-authority check as the reducer's fold — an entry from a non-admin issuer
87
+ * never projects.
88
+ */ export function projectCircleMembers(entries, isAdmin) {
119
89
  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;
90
+ for (const { verified } of authorizedEntries(entries, isAdmin)){
91
+ const { subject, value } = verified.entry;
92
+ const hlc = entryOrd(verified.entry);
93
+ // See {@link projectCircleDefs}: no `ord` means foreign or malformed, and a
94
+ // null tombstone HLC would read as active.
95
+ if (hlc == null) continue;
125
96
  if (isRemovedMember(value)) {
126
97
  const existing = rows.get(subject);
127
98
  if (existing == null) continue;
@@ -143,16 +114,25 @@ export const loadVerifiedGroupSettingsEntries = createVerifiedEntryLoader(GROUP_
143
114
  }
144
115
  return rows;
145
116
  }
146
- async function adminAuthorityFor(params) {
147
- const adminEntries = await loadVerifiedAdminEntries(params.store, params.groupID, params.logger);
148
- return adminAuthorityFromEntries(adminEntries, params.anchor);
117
+ function adminAuthorityFor(params) {
118
+ return adminAuthorityFromEntries(selectRoleEntries(params.ledger), params.anchor, params.groupID);
119
+ }
120
+ /**
121
+ * The group's CURRENT open-circle set, folded from the settings ledger under the
122
+ * supplied admin authority. `open` is a derived cache of this set on each circle
123
+ * row, so both the settings→circles and circles←settings reprojection paths
124
+ * derive it from the one authoritative fold and converge regardless of which
125
+ * entry (the `circle.def` or the `group.settings` opening it) arrives first.
126
+ */ function foldOpenCircleIDs(ledger, groupID, anchor, isAdmin) {
127
+ const entries = selectGroupSettingsEntries(ledger);
128
+ const { settings } = foldLedgerEntries(authorizedEntries(entries, isAdmin), anchor, createGroupSettingsReducer());
129
+ return new Set(settings.get(groupID)?.openCircleIDs ?? []);
149
130
  }
150
131
  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
- ]);
132
+ const { store, groupID, anchor, ledger } = params;
133
+ const isAdmin = adminAuthorityFor(params);
134
+ const entries = selectCircleDefEntries(ledger);
135
+ const openIDs = foldOpenCircleIDs(ledger, groupID, anchor, isAdmin);
156
136
  const rows = projectCircleDefs(entries, isAdmin);
157
137
  for (const [circleID, row] of rows){
158
138
  await store.reconcileCircleProjection({
@@ -162,20 +142,15 @@ export async function reprojectCircleDefs(params) {
162
142
  description: row.description,
163
143
  catalogIDs: row.catalogIDs,
164
144
  hlc: row.hlc,
165
- removedAtHLC: row.removedAtHLC
145
+ removedAtHLC: row.removedAtHLC,
146
+ open: openIDs.has(circleID)
166
147
  });
167
148
  }
168
- const max = maxHLC(entries);
169
- if (max != null) {
170
- await store.setProjectionWatermark(groupID, CIRCLE_DEF_PROJECTION, max);
171
- }
172
149
  }
173
150
  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
- ]);
151
+ const { store, ledger } = params;
152
+ const isAdmin = adminAuthorityFor(params);
153
+ const entries = selectCircleMemberEntries(ledger);
179
154
  const rows = projectCircleMembers(entries, isAdmin);
180
155
  for (const row of rows.values()){
181
156
  await store.reconcileCircleMemberProjection({
@@ -186,10 +161,6 @@ export async function reprojectCircleMembers(params) {
186
161
  removedAtHLC: row.removedAtHLC
187
162
  });
188
163
  }
189
- const max = maxHLC(entries);
190
- if (max != null) {
191
- await store.setProjectionWatermark(groupID, CIRCLE_MEMBER_PROJECTION, max);
192
- }
193
164
  }
194
165
  /**
195
166
  * Reproject group settings onto the group row. Settings are PARTIAL per-field
@@ -197,92 +168,35 @@ export async function reprojectCircleMembers(params) {
197
168
  * fold it under the admin gate, then write the merged fields via the
198
169
  * unconditional `updateGroup`. A field no entry ever set stays at the group's
199
170
  * 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
- }
171
+ */ /**
172
+ * Fold the group's settings ledger under the admin gate and return the merged
173
+ * per-field value, or `undefined` when no authorized settings entry has been
174
+ * folded. This is the same fold {@link reprojectGroupSettings} materializes,
175
+ * exposed so a producer can read the CURRENT folded designation before authoring
176
+ * — what a device projects and what it checks before writing cannot then drift.
177
+ */ export function foldGroupSettings(ledger, groupID, anchor) {
178
+ const isAdmin = adminAuthorityFromEntries(selectRoleEntries(ledger), anchor, groupID);
179
+ const { settings } = foldLedgerEntries(authorizedEntries(selectGroupSettingsEntries(ledger), isAdmin), anchor, createGroupSettingsReducer());
180
+ return settings.get(groupID);
215
181
  }
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).
182
+ export async function reprojectGroupSettings(params) {
183
+ const { store, groupID, anchor, ledger } = params;
184
+ const merged = foldGroupSettings(ledger, groupID, anchor);
185
+ if (merged != null) {
186
+ // name/description live on the group row directly.
283
187
  await store.updateGroup(groupID, {
284
- name: value.name,
285
- description: value.description
188
+ name: merged.name,
189
+ description: merged.description
286
190
  });
287
191
  }
192
+ // `openCircleIDs` is the authoritative open set; project it onto each circle's
193
+ // `open` flag (settings → circles). Runs unconditionally so a fold with no open
194
+ // circles closes every circle in the group.
195
+ await store.reconcileCircleOpenFlags(groupID, merged?.openCircleIDs ?? []);
196
+ // `commitHub` is the group's consensus commit-hub designation; materialize it
197
+ // onto the local `is_commit_hub` flag so the fold, not each device's first
198
+ // hub-bind, decides which bound hub the group commits through. A fold carrying
199
+ // no designation is a no-op here, leaving the local auto-designated flag in
200
+ // place until an admin authors one.
201
+ await store.reconcileCommitHub(groupID, merged?.commitHub);
288
202
  }