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