@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,103 @@
1
+ import { isVerifiedToken, normalizeDID, stringifyToken, verifyToken } from '@kokuin/token';
2
+ export async function signAccessDefaultSet(identity, claim) {
3
+ const signed = await identity.signToken({
4
+ op: 'set',
5
+ ...claim
6
+ }, {
7
+ embedLongForm: true
8
+ });
9
+ return stringifyToken(signed);
10
+ }
11
+ export async function signAccessDefaultRemove(identity, claim) {
12
+ const signed = await identity.signToken({
13
+ op: 'remove',
14
+ ...claim
15
+ }, {
16
+ embedLongForm: true
17
+ });
18
+ return stringifyToken(signed);
19
+ }
20
+ function isPermissionType(value) {
21
+ return value === 'read' || value === 'write';
22
+ }
23
+ /** The levels the store's read predicate is written for, and no others. */ const ACCESS_LEVELS = new Set([
24
+ 'only_owner',
25
+ 'anyone',
26
+ 'restricted'
27
+ ]);
28
+ function isDIDList(value) {
29
+ return value == null || Array.isArray(value) && value.every((entry)=>typeof entry === 'string');
30
+ }
31
+ /**
32
+ * Every field, not just `level`.
33
+ *
34
+ * This is the only gate between the wire and `setUserModelAccessDefault`: the
35
+ * applier writes the rule verbatim. Checking `level` alone let a member land an
36
+ * arbitrary string as an access level, and non-array values in the allow-lists,
37
+ * in its own rows on every peer — shapes the receiving device's SQL read
38
+ * predicate was never written for. The blast radius is the sender's own rows
39
+ * (the applier is sender-bound), which is a reason it is not urgent, not a
40
+ * reason to accept the shape.
41
+ */ function isRule(value) {
42
+ if (value == null || typeof value !== 'object') {
43
+ return false;
44
+ }
45
+ const rule = value;
46
+ return ACCESS_LEVELS.has(rule.level) && isDIDList(rule.allowedDIDs) && isDIDList(rule.allowedCircles) && // Accepted from the wire but never minted locally: `setModelAccessDefaults`
47
+ // exposes circles only. Refusing it here would drop a frame a peer on an
48
+ // older build can still legitimately send, so it is validated and kept.
49
+ isDIDList(rule.allowedGroups);
50
+ }
51
+ /**
52
+ * Verify a signed access-default token and extract its claim. Returns `null`
53
+ * (never throws) when the token is unparseable, unsigned (`alg: 'none'`), or
54
+ * structurally malformed — an attacker cannot forge an `iss` this way. The
55
+ * issuer is the normalized verified `iss`.
56
+ */ export async function verifyAccessDefault(token) {
57
+ let verified;
58
+ try {
59
+ verified = await verifyToken(token);
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (!isVerifiedToken(verified)) {
64
+ return null;
65
+ }
66
+ const { iss, op, modelID, hlc } = verified.payload;
67
+ if (typeof modelID !== 'string' || typeof hlc !== 'string') {
68
+ return null;
69
+ }
70
+ const issuer = normalizeDID(iss);
71
+ if (op === 'set') {
72
+ const { permissionType, rule } = verified.payload;
73
+ if (!isPermissionType(permissionType) || !isRule(rule)) {
74
+ return null;
75
+ }
76
+ return {
77
+ issuer,
78
+ claim: {
79
+ op: 'set',
80
+ modelID,
81
+ permissionType,
82
+ rule,
83
+ hlc
84
+ }
85
+ };
86
+ }
87
+ if (op === 'remove') {
88
+ const { permissionTypes } = verified.payload;
89
+ if (!Array.isArray(permissionTypes) || !permissionTypes.every(isPermissionType)) {
90
+ return null;
91
+ }
92
+ return {
93
+ issuer,
94
+ claim: {
95
+ op: 'remove',
96
+ modelID,
97
+ permissionTypes,
98
+ hlc
99
+ }
100
+ };
101
+ }
102
+ return null;
103
+ }
@@ -1,130 +1,55 @@
1
- import type { Logger } from '@kubun/logger';
2
- import type { P2PStoreAPI } from '@kubun/store-p2p';
3
- import type { GroupAnchor } from './group-anchor.js';
4
- import { type VerifiedLedgerEntry } from './ledger-entry.js';
5
- import { type FoldInput, type LedgerReducer } from './ledger-fold.js';
6
- /** Ledger entry `type` for an admin-role grant or revocation. */
7
- export declare const ADMIN_ROLE_ENTRY_TYPE = "group.role.admin";
1
+ import { type GroupAnchor, type LedgerLogEntry, type RoleValue } from '@kumiai/mls';
2
+ import type { PositionedFoldInput } from './ledger.js';
8
3
  /**
9
- * Projection key for the admin-roster watermark. One projection per entry type
10
- * in this design, so the key is the entry type itself.
4
+ * Ledger entry `type` for a role grant or demotion. This is `@kumiai/mls`'s own
5
+ * interpreted type: the same signed entry the library folds into the roster its
6
+ * commit policy enforces, so kubun's projection and the MLS authority that
7
+ * accepts or refuses a commit can never disagree about who is an admin.
11
8
  */
12
- export declare const ADMIN_ROSTER_PROJECTION = "group.role.admin";
9
+ export declare const ADMIN_ROLE_ENTRY_TYPE = "kumiai.role";
13
10
  /**
14
- * The claim carried by an admin-role entry. `subject` (on the entry) is the
15
- * member DID the claim is about; this value says whether that member becomes an
16
- * admin or loses the role.
11
+ * The claim carried by a role entry: the permission the subject holds. Demotion
12
+ * is `member`, not a distinct "revoked" value the library's roster is a
13
+ * DID→permission map, and an absent admin is simply a member.
17
14
  */
18
- export type AdminRoleValue = 'admin' | 'revoked';
15
+ export type AdminRoleValue = RoleValue;
19
16
  /** Folded admin set: the normalized DIDs currently holding the admin role. */
20
17
  export type AdminRosterState = {
21
18
  admins: Set<string>;
22
19
  };
23
20
  /**
24
- * Self-referential admin roster. Authority is rooted at the genesis anchor (the
25
- * creator is the epoch-0 admin) and grows only through admins-so-far: every
26
- * grant or revocation must be issued by a DID already in the set accumulated
27
- * from strictly-earlier entries. That state-so-far check is what makes rotation
28
- * sound — an admin can revoke the very creator that granted them without
29
- * retroactively voiding their own earlier grants.
30
- */
31
- export declare const adminRosterReducer: LedgerReducer<AdminRoleValue, AdminRosterState>;
32
- /**
33
- * Fold the admin-role ledger into the current admin set. With `atHLC`, only
34
- * entries at or before that HLC are folded, yielding the exact was-admin-at-HLC
35
- * answer the current-only role column cannot express. The string `<=` compare on
36
- * the serialized HLC is the total order (zero-padded, lexicographic), matching
37
- * the membership-removal LWW elsewhere in the store.
38
- */
39
- export declare function foldAdminRoster(entries: Array<FoldInput<AdminRoleValue>>, anchor: GroupAnchor, atHLC?: string): AdminRosterState;
40
- export type ProjectAdminRosterParams = {
41
- store: P2PStoreAPI;
42
- groupID: string;
43
- anchor: GroupAnchor;
44
- /** The group's admin-role ledger entries, in any order. */
45
- entries: Array<FoldInput<AdminRoleValue>>;
46
- /** Current active members whose role overlay should be reconciled. */
47
- memberDIDs: Array<string>;
48
- };
49
- /**
50
- * Project the folded admin set onto the store's `role` column. Membership stays
51
- * MLS-native — this only overlays a role on members that already exist: each
52
- * current member is set to `admin` when the fold places them in the set and
53
- * `member` otherwise. `setGroupMemberRole` is update-only, so a DID in the
54
- * admin set with no membership row is silently skipped.
55
- */
56
- export declare function projectAdminRoster(params: ProjectAdminRosterParams): Promise<void>;
57
- /**
58
- * Load the group's admin-role ledger entries and re-verify each `signed_token`
59
- * into a fold input. The stored token is re-verified rather than trusting the
60
- * decoded `value` column: the ledger is the source of truth and must stay
61
- * self-verifying, so a stored token that no longer verifies is dropped and the
62
- * rest survive. Returned in store order; the fold sorts by total order. Results
63
- * are memoized per group on the stored id set (see {@link verifiedAdminEntryCache}).
64
- */
65
- export declare function loadVerifiedAdminEntries(store: P2PStoreAPI, groupID: string, logger?: Logger): Promise<Array<FoldInput<AdminRoleValue>>>;
66
- /**
67
- * Re-project the admin overlay from the stored ledger. The active member set
68
- * bounds the overlay — `projectAdminRoster` only touches rows that already
69
- * exist, so an admin DID with no membership is silently skipped.
70
- */
71
- export type ProjectAdminRosterFromStoreParams = {
72
- store: P2PStoreAPI;
73
- groupID: string;
74
- anchor: GroupAnchor;
75
- logger?: Logger;
76
- };
77
- export declare function projectAdminRosterFromStore(params: ProjectAdminRosterFromStoreParams): Promise<void>;
78
- /**
79
- * Was `did` a group admin at `atHLC`? Authority is folded from the
80
- * anchor-seeded ledger evaluated at the operation's HLC (rotation-aware:
81
- * was-admin-at-HLC, not is-admin-now), conjoined with membership at that HLC.
82
- * Folding from the genesis anchor — not the local `role` column — is what makes
83
- * a receiver agree with the author's peer: the creator is the epoch-0 admin on
84
- * every peer regardless of how its membership row's role was written.
21
+ * Fold the role ledger into the admin set.
85
22
  *
86
- * Admin implies membership: an MLS-removed member loses admin regardless of the
87
- * ledger, so membership-at-HLC is checked first and short-circuits.
88
- */
89
- export type IsLedgerAdminAtHLCParams = {
90
- store: P2PStoreAPI;
91
- groupID: string;
92
- anchor: GroupAnchor;
93
- did: string;
94
- atHLC: string;
95
- logger?: Logger;
96
- };
97
- export declare function isLedgerAdminAtHLC(params: IsLedgerAdminAtHLCParams): Promise<boolean>;
98
- /**
99
- * Authoritatively rebuild the admin overlay from the whole ledger and reset the
100
- * watermark to the newest folded entry. Used on a first projection, an
101
- * out-of-order arrival, or drift repair — anywhere the cached projection can no
102
- * longer be trusted as a fold-through-watermark. Seeds the admin set from the
103
- * genesis anchor (the creator is the epoch-0 admin), so it never relies on a
104
- * pre-existing `role` value in the store.
23
+ * With `throughPosition`, only entries enacted at or before that ledger slot are
24
+ * folded the roster as it stood at that point in the group's history. That is
25
+ * the was-admin-then answer the current-only `role` column cannot express, and
26
+ * unlike the HLC it used to be keyed on, a position is assigned by the commit
27
+ * chain rather than asserted by the signer, so it cannot be backdated.
28
+ *
29
+ * The entries are folded in the order given, which is the order the handle's log
30
+ * holds them. Authority is rooted at the genesis anchor (the creator is the
31
+ * epoch-0 admin) and grows only through admins-so-far — `foldRoster` also drops
32
+ * an entry signed for another group and one that would leave the roster with
33
+ * zero admins.
105
34
  */
106
- export type ReprojectAdminRosterParams = {
107
- store: P2PStoreAPI;
108
- groupID: string;
109
- anchor: GroupAnchor;
110
- logger?: Logger;
111
- };
112
- export declare function reprojectAdminRoster(params: ReprojectAdminRosterParams): Promise<void>;
35
+ export declare function foldAdminRoster(entries: Array<PositionedFoldInput<RoleValue>>, anchor: GroupAnchor, groupID: string, throughPosition?: number): AdminRosterState;
113
36
  /**
114
- * Apply a single just-appended in-order entry onto the current projection
115
- * without re-folding the whole ledger. The caller guarantees the entry is
116
- * strictly newer than the watermark, so the projection already equals the
117
- * fold-so-far: the current admins are exactly the members projected with role
118
- * `admin`. Authority is read from that projection only a current admin may
119
- * grant or revoke so an entry from a non-admin issuer is dropped (never
120
- * thrown). `setGroupMemberRole` is update-only, so a grant for a DID with no
121
- * membership row is silently skipped; admin implies membership and membership
122
- * stays MLS-native.
37
+ * Select the role entries out of a group's control ledger, as fold inputs
38
+ * carrying the ledger slot each one occupies.
39
+ *
40
+ * Every entry is already verified: the log is the handle's own, and each of its
41
+ * doors`GroupHandle.applyLedgerEntries`, `bootstrapLedger`, an accepted
42
+ * commitre-verifies what it admits. Nothing left to check, nothing to
43
+ * memoize; this replaced a loader that re-verified every stored `signed_token`
44
+ * per authority check, needed only because the SQL row could not vouch for
45
+ * itself.
46
+ *
47
+ * The position indexes the WHOLE ledger, not the selected subset, so a role
48
+ * entry's slot is comparable with a circle entry's — which is what makes "was
49
+ * this issuer an admin when that entry was enacted" answerable at all.
50
+ *
51
+ * The cast is unavoidable: the log is typed over an unknown claim value, and the
52
+ * entry `type` is what says the value is a role, so selecting by that type is
53
+ * what makes the narrowing true.
123
54
  */
124
- export type ApplyAdminEntryIncrementalParams = {
125
- store: P2PStoreAPI;
126
- groupID: string;
127
- verified: VerifiedLedgerEntry<AdminRoleValue>;
128
- logger?: Logger;
129
- };
130
- export declare function applyAdminEntryIncremental(params: ApplyAdminEntryIncrementalParams): Promise<void>;
55
+ export declare function selectRoleEntries(ledger: ReadonlyArray<LedgerLogEntry>): Array<PositionedFoldInput<RoleValue>>;
@@ -1,184 +1,69 @@
1
- import { normalizeDID } from '@kokuin/token';
2
- import { verifyLedgerEntry } from './ledger-entry.js';
3
- import { foldLedger } from './ledger-fold.js';
4
- /** Ledger entry `type` for an admin-role grant or revocation. */ export const ADMIN_ROLE_ENTRY_TYPE = 'group.role.admin';
1
+ import { foldRoster, ROLE_ENTRY_TYPE } from '@kumiai/mls';
5
2
  /**
6
- * Projection key for the admin-roster watermark. One projection per entry type
7
- * in this design, so the key is the entry type itself.
8
- */ export const ADMIN_ROSTER_PROJECTION = ADMIN_ROLE_ENTRY_TYPE;
9
- /**
10
- * Self-referential admin roster. Authority is rooted at the genesis anchor (the
11
- * creator is the epoch-0 admin) and grows only through admins-so-far: every
12
- * grant or revocation must be issued by a DID already in the set accumulated
13
- * from strictly-earlier entries. That state-so-far check is what makes rotation
14
- * sound — an admin can revoke the very creator that granted them without
15
- * retroactively voiding their own earlier grants.
16
- */ export const adminRosterReducer = {
17
- type: ADMIN_ROLE_ENTRY_TYPE,
18
- seed: (anchor)=>({
19
- admins: new Set([
20
- normalizeDID(anchor.creatorDID)
21
- ])
22
- }),
23
- verifyAuthority: (verified, state)=>state.admins.has(verified.issuer),
24
- apply: (verified, state)=>{
25
- const admins = new Set(state.admins);
26
- const subject = normalizeDID(verified.entry.subject);
27
- if (verified.entry.value === 'admin') {
28
- admins.add(subject);
29
- } else if (verified.entry.value === 'revoked') {
30
- admins.delete(subject);
31
- } else {
32
- // Unknown claim value: leave the set untouched rather than guess intent.
33
- return state;
3
+ * Ledger entry `type` for a role grant or demotion. This is `@kumiai/mls`'s own
4
+ * interpreted type: the same signed entry the library folds into the roster its
5
+ * commit policy enforces, so kubun's projection and the MLS authority that
6
+ * accepts or refuses a commit can never disagree about who is an admin.
7
+ */ export const ADMIN_ROLE_ENTRY_TYPE = ROLE_ENTRY_TYPE;
8
+ function adminsFromRoles(roles) {
9
+ const admins = new Set();
10
+ for (const [did, permission] of roles){
11
+ if (permission === 'admin') {
12
+ admins.add(did);
34
13
  }
35
- return {
36
- admins
37
- };
38
- }
39
- };
40
- /**
41
- * Fold the admin-role ledger into the current admin set. With `atHLC`, only
42
- * entries at or before that HLC are folded, yielding the exact was-admin-at-HLC
43
- * answer the current-only role column cannot express. The string `<=` compare on
44
- * the serialized HLC is the total order (zero-padded, lexicographic), matching
45
- * the membership-removal LWW elsewhere in the store.
46
- */ export function foldAdminRoster(entries, anchor, atHLC) {
47
- const scoped = atHLC == null ? entries : entries.filter(({ verified })=>verified.entry.hlc <= atHLC);
48
- return foldLedger(scoped, anchor, adminRosterReducer);
49
- }
50
- /**
51
- * Project the folded admin set onto the store's `role` column. Membership stays
52
- * MLS-native — this only overlays a role on members that already exist: each
53
- * current member is set to `admin` when the fold places them in the set and
54
- * `member` otherwise. `setGroupMemberRole` is update-only, so a DID in the
55
- * admin set with no membership row is silently skipped.
56
- */ export async function projectAdminRoster(params) {
57
- const { store, groupID, anchor, entries, memberDIDs } = params;
58
- const { admins } = foldAdminRoster(entries, anchor);
59
- for (const memberDID of memberDIDs){
60
- const role = admins.has(normalizeDID(memberDID)) ? 'admin' : 'member';
61
- await store.setGroupMemberRole(groupID, memberDID, role);
62
14
  }
15
+ return admins;
63
16
  }
64
17
  /**
65
- * Memoized verified admin entries, one slot per group keyed by the exact set of
66
- * stored entry ids. Verifying every stored token is the dominant cost of an
67
- * authority check, and the gate folds on every control op, so re-verifying an
68
- * unchanged ledger each time is pure waste. Reading the id set stays cheap (an
69
- * indexed scan); signatures are re-verified only when the set changes. The key
70
- * is derived purely from content-addressed entry ids — never the `role` column —
71
- * so the cache can never reintroduce the column-trust bypass: a forged or
72
- * altered entry produces a different id set, a different key, and a full
73
- * re-verification. One slot per group bounds memory and self-invalidates, since
74
- * the ledger is append-only and every append changes the id set.
18
+ * Fold the role ledger into the admin set.
75
19
  *
76
- * Cross-instance safety: this Map is process-global, shared across store
77
- * instances and keyed only by group id. Two instances using the same group id
78
- * with different ledgers stay correct because differing ledgers yield differing
79
- * keys (entry ids are content digests); the only shared key is the empty-ledger
80
- * `''`, whose cached value is `[]` exactly what either empty ledger produces.
81
- * Group growth is bounded: only device-side peers fold rosters (the blind relay
82
- * hub never calls this path), so the distinct-group set per process is small and
83
- * needs no eviction.
84
- */ const verifiedAdminEntryCache = new Map();
85
- function adminEntryCacheKey(entryIDs) {
86
- return entryIDs.join('\n');
20
+ * With `throughPosition`, only entries enacted at or before that ledger slot are
21
+ * folded the roster as it stood at that point in the group's history. That is
22
+ * the was-admin-then answer the current-only `role` column cannot express, and
23
+ * unlike the HLC it used to be keyed on, a position is assigned by the commit
24
+ * chain rather than asserted by the signer, so it cannot be backdated.
25
+ *
26
+ * The entries are folded in the order given, which is the order the handle's log
27
+ * holds them. Authority is rooted at the genesis anchor (the creator is the
28
+ * epoch-0 admin) and grows only through admins-so-far — `foldRoster` also drops
29
+ * an entry signed for another group and one that would leave the roster with
30
+ * zero admins.
31
+ */ export function foldAdminRoster(entries, anchor, groupID, throughPosition) {
32
+ const scoped = throughPosition == null ? entries : entries.filter(({ position })=>position <= throughPosition);
33
+ const roster = foldRoster(scoped, anchor, groupID);
34
+ return {
35
+ admins: adminsFromRoles(roster.roles)
36
+ };
87
37
  }
88
38
  /**
89
- * Load the group's admin-role ledger entries and re-verify each `signed_token`
90
- * into a fold input. The stored token is re-verified rather than trusting the
91
- * decoded `value` column: the ledger is the source of truth and must stay
92
- * self-verifying, so a stored token that no longer verifies is dropped and the
93
- * rest survive. Returned in store order; the fold sorts by total order. Results
94
- * are memoized per group on the stored id set (see {@link verifiedAdminEntryCache}).
95
- */ export async function loadVerifiedAdminEntries(store, groupID, logger) {
96
- const rows = await store.getLedgerEntriesByType(groupID, ADMIN_ROLE_ENTRY_TYPE);
97
- const key = adminEntryCacheKey(rows.map((row)=>row.entry_id));
98
- const cached = verifiedAdminEntryCache.get(groupID);
99
- if (cached != null && cached.key === key) {
100
- return cached.entries;
101
- }
39
+ * Select the role entries out of a group's control ledger, as fold inputs
40
+ * carrying the ledger slot each one occupies.
41
+ *
42
+ * Every entry is already verified: the log is the handle's own, and each of its
43
+ * doors — `GroupHandle.applyLedgerEntries`, `bootstrapLedger`, an accepted
44
+ * commit re-verifies what it admits. Nothing left to check, nothing to
45
+ * memoize; this replaced a loader that re-verified every stored `signed_token`
46
+ * per authority check, needed only because the SQL row could not vouch for
47
+ * itself.
48
+ *
49
+ * The position indexes the WHOLE ledger, not the selected subset, so a role
50
+ * entry's slot is comparable with a circle entry's — which is what makes "was
51
+ * this issuer an admin when that entry was enacted" answerable at all.
52
+ *
53
+ * The cast is unavoidable: the log is typed over an unknown claim value, and the
54
+ * entry `type` is what says the value is a role, so selecting by that type is
55
+ * what makes the narrowing true.
56
+ */ export function selectRoleEntries(ledger) {
102
57
  const entries = [];
103
- for (const row of rows){
104
- const verified = await verifyLedgerEntry(row.signed_token);
105
- if (verified == null) {
106
- logger?.warn('admin roster: dropped stored entry whose token no longer verifies', {
107
- groupID,
108
- entryID: row.entry_id
109
- });
58
+ for (const [position, held] of ledger.entries()){
59
+ if (held.verified.entry.type !== ADMIN_ROLE_ENTRY_TYPE) {
110
60
  continue;
111
61
  }
112
62
  entries.push({
113
- verified,
114
- entryID: row.entry_id
63
+ verified: held.verified,
64
+ entryID: held.entryID,
65
+ position
115
66
  });
116
67
  }
117
- verifiedAdminEntryCache.set(groupID, {
118
- key,
119
- entries
120
- });
121
68
  return entries;
122
69
  }
123
- export async function projectAdminRosterFromStore(params) {
124
- const { store, groupID, anchor, logger } = params;
125
- const entries = await loadVerifiedAdminEntries(store, groupID, logger);
126
- const memberDIDs = (await store.listGroupMembers(groupID)).map((member)=>member.member_did);
127
- await projectAdminRoster({
128
- store,
129
- groupID,
130
- anchor,
131
- entries,
132
- memberDIDs
133
- });
134
- }
135
- export async function isLedgerAdminAtHLC(params) {
136
- const { store, groupID, anchor, did, atHLC, logger } = params;
137
- if (!await store.isGroupMemberAtHLC(groupID, did, atHLC)) return false;
138
- const entries = await loadVerifiedAdminEntries(store, groupID, logger);
139
- const { admins } = foldAdminRoster(entries, anchor, atHLC);
140
- return admins.has(normalizeDID(did));
141
- }
142
- export async function reprojectAdminRoster(params) {
143
- const { store, groupID, anchor, logger } = params;
144
- const entries = await loadVerifiedAdminEntries(store, groupID, logger);
145
- const memberDIDs = (await store.listGroupMembers(groupID)).map((member)=>member.member_did);
146
- await projectAdminRoster({
147
- store,
148
- groupID,
149
- anchor,
150
- entries,
151
- memberDIDs
152
- });
153
- // Watermark through the newest folded entry: the projection now equals the
154
- // fold of every verified entry, so a later in-order arrival (strictly newer
155
- // HLC) can be applied incrementally instead of forcing a full reproject.
156
- let maxHLC = null;
157
- for (const { verified } of entries){
158
- if (maxHLC == null || verified.entry.hlc > maxHLC) {
159
- maxHLC = verified.entry.hlc;
160
- }
161
- }
162
- if (maxHLC != null) {
163
- await store.setProjectionWatermark(groupID, ADMIN_ROSTER_PROJECTION, maxHLC);
164
- }
165
- }
166
- export async function applyAdminEntryIncremental(params) {
167
- const { store, groupID, verified, logger } = params;
168
- const members = await store.listGroupMembers(groupID);
169
- const currentAdmins = new Set(members.filter((m)=>m.role === 'admin').map((m)=>normalizeDID(m.member_did)));
170
- if (!currentAdmins.has(verified.issuer)) {
171
- logger?.warn('admin roster: dropped incremental entry from a non-admin issuer', {
172
- groupID,
173
- issuer: verified.issuer
174
- });
175
- return;
176
- }
177
- const subject = normalizeDID(verified.entry.subject);
178
- if (verified.entry.value === 'admin') {
179
- await store.setGroupMemberRole(groupID, subject, 'admin');
180
- } else if (verified.entry.value === 'revoked') {
181
- await store.setGroupMemberRole(groupID, subject, 'member');
182
- }
183
- // Unknown value: no-op, mirroring the reducer's leave-untouched policy.
184
- }
@@ -0,0 +1,15 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import type { AnchorStore } from '@kumiai/rpc';
3
+ /**
4
+ * The `@kumiai/rpc` `AnchorStore` port for one group, backed by the device's
5
+ * single-slot anchor store. The port carries no group scope — the group a peer
6
+ * belongs to is fixed for its lifetime — so the adapter closes over `groupID`
7
+ * and threads it into every store call.
8
+ *
9
+ * `null` from `load` MEANS FIRST BOOT AND ONLY FIRST BOOT — the peer answers it by
10
+ * seeding the anchor from its live handle, and MLS ratchets forward so no earlier
11
+ * epoch's secret can be re-exported. A read error must therefore PROPAGATE, never
12
+ * be coerced to `null`: a `null` for a group that HAS an anchor silently derives
13
+ * different topic IDs and partitions this device with nothing reported.
14
+ */
15
+ export declare function createAnchorStore(stores: StoreProvider, groupID: string): AnchorStore;
@@ -0,0 +1,37 @@
1
+ import { getP2PStore } from '@kubun/store-p2p';
2
+ /**
3
+ * The `@kumiai/rpc` `AnchorStore` port for one group, backed by the device's
4
+ * single-slot anchor store. The port carries no group scope — the group a peer
5
+ * belongs to is fixed for its lifetime — so the adapter closes over `groupID`
6
+ * and threads it into every store call.
7
+ *
8
+ * `null` from `load` MEANS FIRST BOOT AND ONLY FIRST BOOT — the peer answers it by
9
+ * seeding the anchor from its live handle, and MLS ratchets forward so no earlier
10
+ * epoch's secret can be re-exported. A read error must therefore PROPAGATE, never
11
+ * be coerced to `null`: a `null` for a group that HAS an anchor silently derives
12
+ * different topic IDs and partitions this device with nothing reported.
13
+ */ export function createAnchorStore(stores, groupID) {
14
+ return {
15
+ async load () {
16
+ const store = await getP2PStore(stores);
17
+ const row = await store.getGroupAnchor(groupID);
18
+ if (row == null) {
19
+ return null;
20
+ }
21
+ // Copied into a fresh `Uint8Array` the port owns, so nothing downstream
22
+ // holds a view onto the adapter's decoded row.
23
+ return {
24
+ secret: new Uint8Array(row.secret),
25
+ epoch: row.epoch
26
+ };
27
+ },
28
+ async save (anchor) {
29
+ const store = await getP2PStore(stores);
30
+ await store.putGroupAnchor({
31
+ groupID,
32
+ secret: anchor.secret,
33
+ epoch: anchor.epoch
34
+ });
35
+ }
36
+ };
37
+ }
@@ -0,0 +1,17 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import type { AppCursorStore } from '@kumiai/rpc';
3
+ /**
4
+ * The `@kumiai/rpc` `AppCursorStore` port for one group, backed by the device's
5
+ * per-topic cursor store. The port keys positions by topic ID alone — a topic
6
+ * ID is already globally unique — so the adapter closes over `groupID` purely
7
+ * to give each row an owner: a deleted group's cursors go with it, rather than
8
+ * staying behind as unreachable rows nothing accounts for.
9
+ *
10
+ * `null` here means this device has never processed a frame on the topic, and
11
+ * the drain answers it by reading from the hub's oldest retained frame. Unlike
12
+ * the anchor, a wrong `null` costs a re-read, not a partition.
13
+ *
14
+ * Positions are opaque log positions: stored and returned verbatim, never
15
+ * compared or parsed here.
16
+ */
17
+ export declare function createAppCursorStore(stores: StoreProvider, groupID: string): AppCursorStore;
@@ -0,0 +1,34 @@
1
+ import { getP2PStore } from '@kubun/store-p2p';
2
+ /**
3
+ * The `@kumiai/rpc` `AppCursorStore` port for one group, backed by the device's
4
+ * per-topic cursor store. The port keys positions by topic ID alone — a topic
5
+ * ID is already globally unique — so the adapter closes over `groupID` purely
6
+ * to give each row an owner: a deleted group's cursors go with it, rather than
7
+ * staying behind as unreachable rows nothing accounts for.
8
+ *
9
+ * `null` here means this device has never processed a frame on the topic, and
10
+ * the drain answers it by reading from the hub's oldest retained frame. Unlike
11
+ * the anchor, a wrong `null` costs a re-read, not a partition.
12
+ *
13
+ * Positions are opaque log positions: stored and returned verbatim, never
14
+ * compared or parsed here.
15
+ */ export function createAppCursorStore(stores, groupID) {
16
+ return {
17
+ async load (topicID) {
18
+ const store = await getP2PStore(stores);
19
+ const row = await store.getAppCursor({
20
+ groupID,
21
+ topicID
22
+ });
23
+ return row?.position ?? null;
24
+ },
25
+ async save (topicID, position) {
26
+ const store = await getP2PStore(stores);
27
+ await store.putAppCursor({
28
+ groupID,
29
+ topicID,
30
+ position
31
+ });
32
+ }
33
+ };
34
+ }