@kubun/plugin-p2p 0.10.1 → 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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- 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 +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- 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 +55 -0
- package/lib/groups/admin-roster.js +69 -0
- 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-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- 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 +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- 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 +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- 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 +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- 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 +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- 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 +155 -1
- 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 +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- 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 +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- 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 +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- 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 +704 -22
- package/lib/types.js +1 -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 +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -89
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { readGroupAnchor } from '@kumiai/mls';
|
|
2
|
+
import { ingestVerifiedLedgerEntry } from './ledger-ingest.js';
|
|
3
|
+
/**
|
|
4
|
+
* The namespace kubun owns on the control ledger. Every entry type kubun mints
|
|
5
|
+
* and projects lives under it: `kubun.circle.member`, `kubun.circle.def`,
|
|
6
|
+
* `kubun.group.settings`. Note the last one — it CONTAINS `group.` without
|
|
7
|
+
* starting with it, which is why this stays a prefix test and must never be
|
|
8
|
+
* loosened to a substring test.
|
|
9
|
+
*/ const KUBUN_ENTRY_TYPE_PREFIX = 'kubun.';
|
|
10
|
+
/**
|
|
11
|
+
* Whether an entry type is one kubun owns, and therefore one kubun's fold
|
|
12
|
+
* projects. Everything outside `kubun.*` is foreign: the handle consumes it and
|
|
13
|
+
* surfaces nothing to kubun, so nothing folds it.
|
|
14
|
+
*
|
|
15
|
+
* Anchored on KUBUN's namespace, not the one `@kumiai/mls` reserves, so an
|
|
16
|
+
* upstream rename (`group.role` → `kumiai.role`) cannot silently change which
|
|
17
|
+
* entries kubun projects.
|
|
18
|
+
*
|
|
19
|
+
* A producer must write rows only for FOREIGN entries: an entry the fold surfaces
|
|
20
|
+
* is appended and projected by ingest, which reads the row's pre-state to decide
|
|
21
|
+
* which domain event it stands for. A pre-written row reads as pre-existing, so
|
|
22
|
+
* the author alone sees a change event where every other device sees a create.
|
|
23
|
+
*
|
|
24
|
+
* This answers what kubun PROJECTS, not what it must OBSERVE — see
|
|
25
|
+
* {@link entriesRetainedByCommit}, whose answer for the role entry is the opposite.
|
|
26
|
+
*/ export function isKubunLedgerEntryType(type) {
|
|
27
|
+
return type.startsWith(KUBUN_ENTRY_TYPE_PREFIX);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The entries a landed commit added to the handle's ledger that kubun does not
|
|
31
|
+
* project but must still OBSERVE. Everything outside `kubun.*`: the handle
|
|
32
|
+
* surfaces none of it, so no kubun fold builds a row from it, yet these entries
|
|
33
|
+
* carry HLCs this device has to merge — a role change stamped by another member
|
|
34
|
+
* is a timestamp this device must sort after.
|
|
35
|
+
*
|
|
36
|
+
* PROJECTS and OBSERVES are different questions — reusing one predicate for both
|
|
37
|
+
* drops the role entry's clock, which reaches this device through no other path.
|
|
38
|
+
*
|
|
39
|
+
* The delta is taken BY INDEX, never by comparing tokens: the handle's ledger is a
|
|
40
|
+
* log, not a set, so a token it already held can legitimately be enacted again.
|
|
41
|
+
* `ledgerLengthBeforeCommit` is the log's length read before `processMessage`.
|
|
42
|
+
*/ export function entriesRetainedByCommit(handle, ledgerLengthBeforeCommit) {
|
|
43
|
+
const retained = [];
|
|
44
|
+
const log = handle.ledger;
|
|
45
|
+
for(let index = Math.max(ledgerLengthBeforeCommit, 0); index < log.length; index++){
|
|
46
|
+
const held = log[index];
|
|
47
|
+
if (held == null || isKubunLedgerEntryType(held.verified.entry.type)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
retained.push({
|
|
51
|
+
verified: held.verified,
|
|
52
|
+
token: held.token
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return retained;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The entries a device's OWN commit would surface, recovered from the
|
|
59
|
+
* post-commit handle's ledger. The sink only fires for a commit a handle
|
|
60
|
+
* PROCESSES — a device merges its own commit instead of processing it, so the
|
|
61
|
+
* local path derives the same list from the tokens it committed, keeping the
|
|
62
|
+
* entries kubun owns and dropping the rest. Both paths then fold identical
|
|
63
|
+
* entries through the same ingest.
|
|
64
|
+
*
|
|
65
|
+
* The receive side surfaces by kumiai's rule (everything kumiai does not reserve
|
|
66
|
+
* for itself) and this side surfaces by kubun's. The two agree on every type
|
|
67
|
+
* kubun commits, which is `kubun.*` plus the role entry kumiai interprets — a
|
|
68
|
+
* commit carrying some third party's namespace would surface remotely and not
|
|
69
|
+
* locally, and kubun mints no such entry.
|
|
70
|
+
*/ export function entriesSurfacedByOwnCommit(handle, tokens) {
|
|
71
|
+
const entries = [];
|
|
72
|
+
for (const token of tokens){
|
|
73
|
+
const held = handle.ledger.find((candidate)=>candidate.token === token);
|
|
74
|
+
if (held == null || !isKubunLedgerEntryType(held.verified.entry.type)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
entries.push(held.verified);
|
|
78
|
+
}
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Recover the signed token a surfaced entry was carried by. The handle's ledger
|
|
83
|
+
* log holds `{ token, verified }` pairs, and the entry a commit surfaces IS the
|
|
84
|
+
* `verified` the log stores for it, so the pair is recovered by identity rather
|
|
85
|
+
* than by re-deriving a digest or re-verifying a signature.
|
|
86
|
+
*/ function tokenForEntry(handle, entry) {
|
|
87
|
+
for (const held of handle.ledger){
|
|
88
|
+
if (held.verified === entry) {
|
|
89
|
+
return held.token;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Fold a landed commit's surfaced entries into the same projection ingest a
|
|
96
|
+
* broadcast entry folds through, so the two sources converge on identical rows.
|
|
97
|
+
* Returns one descriptor per entry that moved a projection, for the caller to
|
|
98
|
+
* emit as domain events.
|
|
99
|
+
*
|
|
100
|
+
* NEVER throws. The MLS advance is the load-bearing state and a projection is a
|
|
101
|
+
* cache derivable from the ledger: a fold fault must not roll back a good
|
|
102
|
+
* commit, because a lane that refuses to advance past a frame re-reads that same
|
|
103
|
+
* frame forever. A faulted entry is logged and skipped; the projection heals on
|
|
104
|
+
* the next reproject (an admin entry's arrival rebuilds every dependent, and a
|
|
105
|
+
* ledger catch-up rebuilds all four from the whole ledger). Each entry folds
|
|
106
|
+
* independently, so one fault does not skip the rest.
|
|
107
|
+
*/ export async function foldCommittedLedgerEntries(params) {
|
|
108
|
+
const { handle, entries, p2pStore, groupID, logger } = params;
|
|
109
|
+
const retained = params.retained ?? [];
|
|
110
|
+
if (entries.length === 0 && retained.length === 0) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
// Retained first, projected second — the enactment order the handle applied
|
|
114
|
+
// them in, which is the order their clocks were stamped in.
|
|
115
|
+
const pending = [
|
|
116
|
+
...retained
|
|
117
|
+
];
|
|
118
|
+
for (const entry of entries){
|
|
119
|
+
const token = tokenForEntry(handle, entry);
|
|
120
|
+
if (token == null) {
|
|
121
|
+
// The ledger log is the only source of an entry's signed token, and an
|
|
122
|
+
// entry the accepting handle surfaced is in it. Nothing to append without
|
|
123
|
+
// one, so log rather than guess.
|
|
124
|
+
logger?.warn('committed ledger entry skipped: no signed token in the handle ledger', {
|
|
125
|
+
groupID,
|
|
126
|
+
type: entry.entry.type
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
pending.push({
|
|
131
|
+
verified: entry,
|
|
132
|
+
token
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const anchor = readGroupAnchor(handle);
|
|
136
|
+
const affected = [];
|
|
137
|
+
for (const { verified } of pending){
|
|
138
|
+
try {
|
|
139
|
+
const result = await ingestVerifiedLedgerEntry({
|
|
140
|
+
p2pStore,
|
|
141
|
+
...params.hlc != null ? {
|
|
142
|
+
hlc: params.hlc
|
|
143
|
+
} : {},
|
|
144
|
+
...anchor != null ? {
|
|
145
|
+
getGroupAnchor: async ()=>anchor
|
|
146
|
+
} : {},
|
|
147
|
+
// The accepting handle is already in hand, so its log is read straight
|
|
148
|
+
// off it. Going back through the registry here would re-acquire a lock
|
|
149
|
+
// this path may already hold.
|
|
150
|
+
getGroupLedger: async ()=>handle.ledger,
|
|
151
|
+
...logger != null ? {
|
|
152
|
+
logger
|
|
153
|
+
} : {}
|
|
154
|
+
}, verified, groupID);
|
|
155
|
+
if (result.ledger != null) {
|
|
156
|
+
affected.push(result.ledger);
|
|
157
|
+
}
|
|
158
|
+
} catch (error) {
|
|
159
|
+
logger?.warn('committed ledger entry projection failed, skipping', {
|
|
160
|
+
groupID,
|
|
161
|
+
type: verified.entry.type,
|
|
162
|
+
error
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return affected;
|
|
167
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { HLC } from '@kubun/hlc';
|
|
2
|
+
import type { Logger } from '@kubun/logger';
|
|
3
|
+
import type { Circle, CircleMember, Group, P2PStoreAPI } from '@kubun/store-p2p';
|
|
4
|
+
import type { GroupAnchor, LedgerLogEntry } from '@kumiai/mls';
|
|
5
|
+
import { type VerifiedLedgerEntry } from './ledger.js';
|
|
6
|
+
/**
|
|
7
|
+
* The local-event descriptor for an applied control-ledger entry. The ingest
|
|
8
|
+
* captures it after reprojecting, while the store still holds the post-state, so
|
|
9
|
+
* a caller can emit the matching local event for the change without re-reading
|
|
10
|
+
* or re-verifying. Each variant carries the projected row plus the
|
|
11
|
+
* create-vs-change / add-vs-remove discriminators the ingest derived by
|
|
12
|
+
* comparing the pre-state it read before reprojecting.
|
|
13
|
+
*/
|
|
14
|
+
export type LedgerAffected = {
|
|
15
|
+
entry: 'circle.def';
|
|
16
|
+
row: Circle;
|
|
17
|
+
/** True when an active circle row existed before this entry (changed, not created). */
|
|
18
|
+
preExisted: boolean;
|
|
19
|
+
/** True when the entry's catalog set differs from the pre-state's (catalogs changed). */
|
|
20
|
+
catalogsChanged: boolean;
|
|
21
|
+
} | {
|
|
22
|
+
entry: 'circle.def.removed';
|
|
23
|
+
row: Circle;
|
|
24
|
+
} | {
|
|
25
|
+
entry: 'circle.member';
|
|
26
|
+
row: CircleMember;
|
|
27
|
+
} | {
|
|
28
|
+
entry: 'circle.member.removed';
|
|
29
|
+
row: CircleMember;
|
|
30
|
+
} | {
|
|
31
|
+
entry: 'group.settings';
|
|
32
|
+
row: Group;
|
|
33
|
+
};
|
|
34
|
+
export type AffectedRow = {
|
|
35
|
+
kind: 'ledger';
|
|
36
|
+
ledger: LedgerAffected;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Whether a group is governed by a genesis anchor. The control gate must treat
|
|
40
|
+
* three cases differently:
|
|
41
|
+
* - `anchored`: the MLS state was read and carries a genesis anchor → ledger authority.
|
|
42
|
+
* - `unanchored`: the MLS state was read and carries no anchor (a group created
|
|
43
|
+
* before the anchor, or external) → legacy role-column authority.
|
|
44
|
+
* - `unknown`: the anchor could not be read at all (e.g. the handle was evicted,
|
|
45
|
+
* or a teardown race) → fail closed. We must NOT fall back to the role-column
|
|
46
|
+
* gate here: an anchored group whose anchor we momentarily can't read would
|
|
47
|
+
* otherwise be authorized by its forgeable `role` column — exactly the
|
|
48
|
+
* cross-peer bypass the ledger closes.
|
|
49
|
+
*/
|
|
50
|
+
type AnchorResolution = {
|
|
51
|
+
status: 'anchored';
|
|
52
|
+
anchor: GroupAnchor;
|
|
53
|
+
ledger: ReadonlyArray<LedgerLogEntry>;
|
|
54
|
+
} | {
|
|
55
|
+
status: 'unanchored';
|
|
56
|
+
} | {
|
|
57
|
+
status: 'unknown';
|
|
58
|
+
};
|
|
59
|
+
/** Inputs the ledger ingest needs — a strict subset of the receive params. */
|
|
60
|
+
export type LedgerIngestParams = {
|
|
61
|
+
p2pStore: P2PStoreAPI;
|
|
62
|
+
/**
|
|
63
|
+
* Device-wide monotonic clock. On a newly-appended entry the receiver merges
|
|
64
|
+
* the entry's HLC into this clock so its own later entries sort strictly after
|
|
65
|
+
* observed ones. Optional: callers that never receive ledger entries (and
|
|
66
|
+
* existing tests) omit it and the merge is skipped.
|
|
67
|
+
*/
|
|
68
|
+
hlc?: HLC;
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a group's genesis anchor, or `null` when none is available yet. An
|
|
71
|
+
* entry is always stored durably, but the projections can only be built once
|
|
72
|
+
* the authenticated epoch-0 root is known; a `null` anchor defers projection
|
|
73
|
+
* without dropping the entry.
|
|
74
|
+
*/
|
|
75
|
+
getGroupAnchor?: (groupID: string) => Promise<GroupAnchor | null>;
|
|
76
|
+
/**
|
|
77
|
+
* Read the group's control ledger — the handle's own log, in enactment order.
|
|
78
|
+
* Every projection folds from it, so a group whose ledger cannot be read is
|
|
79
|
+
* not projectable and the entry's projection defers (it stays durable).
|
|
80
|
+
*
|
|
81
|
+
* A resolver rather than an array because the receive path is handed a group
|
|
82
|
+
* id, not a handle. Callers that already hold one under the registry's mutex
|
|
83
|
+
* pass a closure over it rather than re-acquiring the lock.
|
|
84
|
+
*/
|
|
85
|
+
getGroupLedger?: (groupID: string) => Promise<ReadonlyArray<LedgerLogEntry>>;
|
|
86
|
+
/** Optional logger — warns on per-entry apply failures. */
|
|
87
|
+
logger?: Logger;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Resolve a group's anchor without ever throwing into the receive loop. A
|
|
91
|
+
* missing `getGroupAnchor` (callers that never project) is treated as
|
|
92
|
+
* `unanchored`; a read that throws is `unknown` (fail closed), distinct from a
|
|
93
|
+
* read that succeeds with no anchor (`unanchored`).
|
|
94
|
+
*/
|
|
95
|
+
export declare function resolveGroupAnchor(params: {
|
|
96
|
+
getGroupAnchor?: (groupID: string) => Promise<GroupAnchor | null>;
|
|
97
|
+
getGroupLedger?: (groupID: string) => Promise<ReadonlyArray<LedgerLogEntry>>;
|
|
98
|
+
}, groupID: string): Promise<AnchorResolution>;
|
|
99
|
+
/** The outcome of folding one verified entry into the projections. */
|
|
100
|
+
export type LedgerIngestResult = {
|
|
101
|
+
/**
|
|
102
|
+
* True when the entry was folded. Always true today — the entry is already in
|
|
103
|
+
* the handle's ledger by the time this runs, and this step only projects.
|
|
104
|
+
* Kept so a caller that folds a batch can still report a count.
|
|
105
|
+
*/
|
|
106
|
+
applied: boolean;
|
|
107
|
+
/** The projected row + discriminators, when this entry moved a projection. */
|
|
108
|
+
ledger?: LedgerAffected;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Fold ONE control-ledger entry a commit enacted into the projections that read
|
|
112
|
+
* it, and merge its clock.
|
|
113
|
+
*
|
|
114
|
+
* The entry itself needs no storing: it is already in the handle's ledger, put
|
|
115
|
+
* there by the commit that enacted it, and every projection folds from there.
|
|
116
|
+
* What is left is the device-local consequence — the observed HLC, the
|
|
117
|
+
* materialized rows, and the descriptor the caller announces from.
|
|
118
|
+
*
|
|
119
|
+
* The caller owns authentication. An entry a commit surfaced was already
|
|
120
|
+
* notarized by the handle that accepted the commit, so it is not re-verified
|
|
121
|
+
* here; this function trusts `verified` and never re-checks the signature.
|
|
122
|
+
*/
|
|
123
|
+
export declare function ingestVerifiedLedgerEntry(params: LedgerIngestParams, verified: VerifiedLedgerEntry, groupID: string): Promise<LedgerIngestResult>;
|
|
124
|
+
export {};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { HLC } from '@kubun/hlc';
|
|
2
|
+
import { ADMIN_ROLE_ENTRY_TYPE } from './admin-roster.js';
|
|
3
|
+
import { reprojectCircleDefs, reprojectCircleMembers, reprojectGroupSettings } from './circle-projection.js';
|
|
4
|
+
import { CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, decodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
|
|
5
|
+
import { entryOrd } from './ledger.js';
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a group's anchor without ever throwing into the receive loop. A
|
|
8
|
+
* missing `getGroupAnchor` (callers that never project) is treated as
|
|
9
|
+
* `unanchored`; a read that throws is `unknown` (fail closed), distinct from a
|
|
10
|
+
* read that succeeds with no anchor (`unanchored`).
|
|
11
|
+
*/ export async function resolveGroupAnchor(params, groupID) {
|
|
12
|
+
if (params.getGroupAnchor == null) {
|
|
13
|
+
return {
|
|
14
|
+
status: 'unanchored'
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
let anchor;
|
|
18
|
+
try {
|
|
19
|
+
anchor = await params.getGroupAnchor(groupID);
|
|
20
|
+
} catch {
|
|
21
|
+
return {
|
|
22
|
+
status: 'unknown'
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (anchor == null) {
|
|
26
|
+
return {
|
|
27
|
+
status: 'unanchored'
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// The ledger is resolved with the anchor, not beside it: both are read off the
|
|
31
|
+
// same handle, and every projection needs both. Resolving them together is what
|
|
32
|
+
// makes an unreadable ledger indistinguishable from an unreadable anchor —
|
|
33
|
+
// `unknown`, fail closed — instead of projecting an empty fold over real rows.
|
|
34
|
+
if (params.getGroupLedger == null) {
|
|
35
|
+
return {
|
|
36
|
+
status: 'unknown'
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return {
|
|
41
|
+
status: 'anchored',
|
|
42
|
+
anchor,
|
|
43
|
+
ledger: await params.getGroupLedger(groupID)
|
|
44
|
+
};
|
|
45
|
+
} catch {
|
|
46
|
+
return {
|
|
47
|
+
status: 'unknown'
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Fold ONE control-ledger entry a commit enacted into the projections that read
|
|
53
|
+
* it, and merge its clock.
|
|
54
|
+
*
|
|
55
|
+
* The entry itself needs no storing: it is already in the handle's ledger, put
|
|
56
|
+
* there by the commit that enacted it, and every projection folds from there.
|
|
57
|
+
* What is left is the device-local consequence — the observed HLC, the
|
|
58
|
+
* materialized rows, and the descriptor the caller announces from.
|
|
59
|
+
*
|
|
60
|
+
* The caller owns authentication. An entry a commit surfaced was already
|
|
61
|
+
* notarized by the handle that accepted the commit, so it is not re-verified
|
|
62
|
+
* here; this function trusts `verified` and never re-checks the signature.
|
|
63
|
+
*/ export async function ingestVerifiedLedgerEntry(params, verified, groupID) {
|
|
64
|
+
const { p2pStore } = params;
|
|
65
|
+
// An entry `@kumiai/mls` minted itself — the invitee's role entry, signed
|
|
66
|
+
// inside `createInvite` — carries no `ord`, so there is no clock to merge.
|
|
67
|
+
const ord = entryOrd(verified.entry);
|
|
68
|
+
if (params.hlc != null && ord != null) {
|
|
69
|
+
try {
|
|
70
|
+
params.hlc.receive(HLC.parse(ord));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
params.logger?.warn('ledger entry HLC merge failed', {
|
|
73
|
+
groupID,
|
|
74
|
+
hlc: ord,
|
|
75
|
+
error
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// The overlay can only be projected once the authenticated epoch-0 root is
|
|
80
|
+
// known. Defer projection when there is no anchor or it can't be read right
|
|
81
|
+
// now — the entry is safe in the handle's ledger either way, and a later entry
|
|
82
|
+
// or a reproject heals the overlay.
|
|
83
|
+
const resolution = await resolveGroupAnchor(params, groupID);
|
|
84
|
+
if (resolution.status !== 'anchored') {
|
|
85
|
+
params.logger?.debug('ledger entry role projection deferred', {
|
|
86
|
+
groupID,
|
|
87
|
+
type: verified.entry.type,
|
|
88
|
+
reason: resolution.status === 'unknown' ? 'anchor unavailable' : 'no genesis anchor'
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
applied: true
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const { anchor, ledger } = resolution;
|
|
95
|
+
// Local-event descriptor for this applied entry — populated by the projected
|
|
96
|
+
// control branches below so a caller can emit the same local event a producer
|
|
97
|
+
// fires. The pre-state each branch reads BEFORE reprojecting decides
|
|
98
|
+
// create-vs-change / add-vs-remove; the post-state is the projected row the
|
|
99
|
+
// event carries.
|
|
100
|
+
let ledgerAffected;
|
|
101
|
+
// Route by entry type to the matching projection, each of which reprojects the
|
|
102
|
+
// whole ledger unconditionally. An entry is judged against the ledger prefix
|
|
103
|
+
// through its own slot, so a full fold reaches the same answer an incremental
|
|
104
|
+
// one would and there is no cached-projection-is-stale case to detect — the
|
|
105
|
+
// watermark and incremental appliers this once needed are gone. An unprojected
|
|
106
|
+
// type falls through; the durable append above is the whole of its effect.
|
|
107
|
+
switch(verified.entry.type){
|
|
108
|
+
case ADMIN_ROLE_ENTRY_TYPE:
|
|
109
|
+
{
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case CIRCLE_DEF_ENTRY_TYPE:
|
|
113
|
+
{
|
|
114
|
+
const defEntry = verified;
|
|
115
|
+
// Pre-state (including a tombstoned row) read before reprojecting: an
|
|
116
|
+
// active row before this entry means the circle existed (a change, not a
|
|
117
|
+
// create); its `catalog_ids` is the baseline for catalogs-changed.
|
|
118
|
+
const before = await p2pStore.getCircle(defEntry.entry.subject, {
|
|
119
|
+
includeRemoved: true
|
|
120
|
+
});
|
|
121
|
+
const preActive = before != null && before.removed_at_hlc == null;
|
|
122
|
+
await reprojectCircleDefs({
|
|
123
|
+
store: p2pStore,
|
|
124
|
+
groupID,
|
|
125
|
+
anchor,
|
|
126
|
+
ledger,
|
|
127
|
+
logger: params.logger
|
|
128
|
+
});
|
|
129
|
+
// Post-state decides which local event to emit. A now-tombstoned row maps
|
|
130
|
+
// to a delete; an active row maps to create (no active pre-state) or
|
|
131
|
+
// change (active pre-state). A null post-state (a non-admin entry the
|
|
132
|
+
// projection dropped, or a tombstone with no prior row) emits nothing.
|
|
133
|
+
const after = await p2pStore.getCircle(defEntry.entry.subject, {
|
|
134
|
+
includeRemoved: true
|
|
135
|
+
});
|
|
136
|
+
if (after != null) {
|
|
137
|
+
if (after.removed_at_hlc != null) {
|
|
138
|
+
ledgerAffected = {
|
|
139
|
+
entry: 'circle.def.removed',
|
|
140
|
+
row: after
|
|
141
|
+
};
|
|
142
|
+
} else {
|
|
143
|
+
const catalogsChanged = preActive && before != null && JSON.stringify(before.catalog_ids) !== JSON.stringify(after.catalog_ids);
|
|
144
|
+
ledgerAffected = {
|
|
145
|
+
entry: 'circle.def',
|
|
146
|
+
row: after,
|
|
147
|
+
preExisted: preActive,
|
|
148
|
+
catalogsChanged
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case CIRCLE_MEMBER_ENTRY_TYPE:
|
|
155
|
+
{
|
|
156
|
+
const memberEntry = verified;
|
|
157
|
+
const decoded = decodeCircleMemberSubject(memberEntry.entry.subject);
|
|
158
|
+
await reprojectCircleMembers({
|
|
159
|
+
store: p2pStore,
|
|
160
|
+
groupID,
|
|
161
|
+
anchor,
|
|
162
|
+
ledger,
|
|
163
|
+
logger: params.logger
|
|
164
|
+
});
|
|
165
|
+
// Post-state decides the local event: a now-tombstoned row maps to a
|
|
166
|
+
// remove, an active row to an add (an add and a role-change both map to
|
|
167
|
+
// `circleMemberAdded` — there is no distinct role-change event). A null
|
|
168
|
+
// post-state (a malformed subject, a non-admin entry the projection
|
|
169
|
+
// dropped, or a tombstone with no prior row) emits nothing.
|
|
170
|
+
if (decoded != null) {
|
|
171
|
+
const after = await p2pStore.getCircleMember(decoded.circleID, decoded.memberDID);
|
|
172
|
+
if (after != null) {
|
|
173
|
+
ledgerAffected = after.removed_at_hlc != null ? {
|
|
174
|
+
entry: 'circle.member.removed',
|
|
175
|
+
row: after
|
|
176
|
+
} : {
|
|
177
|
+
entry: 'circle.member',
|
|
178
|
+
row: after
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case GROUP_SETTINGS_ENTRY_TYPE:
|
|
185
|
+
{
|
|
186
|
+
await reprojectGroupSettings({
|
|
187
|
+
store: p2pStore,
|
|
188
|
+
groupID,
|
|
189
|
+
anchor,
|
|
190
|
+
ledger,
|
|
191
|
+
logger: params.logger
|
|
192
|
+
});
|
|
193
|
+
// Group settings are partial-merge with no tombstone and no
|
|
194
|
+
// create-vs-change distinction — the producer always emits
|
|
195
|
+
// `groupDataChanged` — so the post-state group row alone is the event.
|
|
196
|
+
const after = await p2pStore.getGroup(groupID);
|
|
197
|
+
if (after != null) {
|
|
198
|
+
ledgerAffected = {
|
|
199
|
+
entry: 'group.settings',
|
|
200
|
+
row: after
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return ledgerAffected != null ? {
|
|
207
|
+
applied: true,
|
|
208
|
+
ledger: ledgerAffected
|
|
209
|
+
} : {
|
|
210
|
+
applied: true
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type FoldDrop, type FoldInput, type GroupAnchor, type LedgerReducer } from '@kumiai/mls';
|
|
2
|
+
export { type FoldDrop, type FoldInput, type LedgerEntry, type LedgerReducer, ledgerEntryDigest, signLedgerEntry, type VerifiedLedgerEntry, verifyLedgerEntry, } from '@kumiai/mls';
|
|
3
|
+
/**
|
|
4
|
+
* A fold input paired with the slot it occupies in the group's control ledger.
|
|
5
|
+
*
|
|
6
|
+
* The position, not the entry, is the identity that matters for ordering. The
|
|
7
|
+
* handle's log records what each commit ENACTED rather than a set of claims, so
|
|
8
|
+
* it can hold the same token twice — a repeat is the only way to express a
|
|
9
|
+
* demotion back to a previously-held role — and two such entries share both
|
|
10
|
+
* their `ord` and their content-addressed `entryID`. Nothing derived from the
|
|
11
|
+
* entry can tell those two apart; only the slot can.
|
|
12
|
+
*/
|
|
13
|
+
export type PositionedFoldInput<TValue> = FoldInput<TValue> & {
|
|
14
|
+
/** Index in the handle's ledger, in enactment order. */
|
|
15
|
+
position: number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The HLC kubun signed into an entry, or `null` when the entry carries none.
|
|
19
|
+
*
|
|
20
|
+
* DESCRIPTIVE ONLY: nothing orders or authorizes by it. An entry minted inside
|
|
21
|
+
* `createInvite` has no `ord` at all — kumiai orders by the epoch chain — so
|
|
22
|
+
* absence is normal and must stay distinguishable from a real clock.
|
|
23
|
+
*/
|
|
24
|
+
export declare function entryOrd(entry: {
|
|
25
|
+
ord?: string;
|
|
26
|
+
}): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Fold a group's control ledger into a reducer's projection.
|
|
29
|
+
*
|
|
30
|
+
* Entries fold in the order given, which is the order the handle's log holds
|
|
31
|
+
* them: enactment order, assigned by the authenticated MLS epoch chain. Every
|
|
32
|
+
* peer holding the same ledger holds the same sequence, so the fold is
|
|
33
|
+
* deterministic without kubun imposing an order of its own.
|
|
34
|
+
*
|
|
35
|
+
* Kubun used to sort by the signed `ord` — an order the author asserts, and so
|
|
36
|
+
* one the author can backdate: a demoted admin could date an entry into the
|
|
37
|
+
* window where it still held the role. A position is assigned by the commit
|
|
38
|
+
* chain and cannot be claimed.
|
|
39
|
+
*
|
|
40
|
+
* Authority is NOT checked here: the caller filters unauthorized entries out
|
|
41
|
+
* first, because the reducer's `verifyAuthority` sees only the entry and the
|
|
42
|
+
* prefix an entry is judged against is a property of its slot.
|
|
43
|
+
*/
|
|
44
|
+
export declare function foldLedgerEntries<TValue, TState>(entries: Array<FoldInput<TValue>>, anchor: GroupAnchor, reducer: LedgerReducer<TValue, TState>, onDrop?: (drop: FoldDrop) => void): TState;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { foldLedger } from '@kumiai/mls';
|
|
2
|
+
export { ledgerEntryDigest, signLedgerEntry, verifyLedgerEntry } from '@kumiai/mls';
|
|
3
|
+
/**
|
|
4
|
+
* The HLC kubun signed into an entry, or `null` when the entry carries none.
|
|
5
|
+
*
|
|
6
|
+
* DESCRIPTIVE ONLY: nothing orders or authorizes by it. An entry minted inside
|
|
7
|
+
* `createInvite` has no `ord` at all — kumiai orders by the epoch chain — so
|
|
8
|
+
* absence is normal and must stay distinguishable from a real clock.
|
|
9
|
+
*/ export function entryOrd(entry) {
|
|
10
|
+
return entry.ord ?? null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Fold a group's control ledger into a reducer's projection.
|
|
14
|
+
*
|
|
15
|
+
* Entries fold in the order given, which is the order the handle's log holds
|
|
16
|
+
* them: enactment order, assigned by the authenticated MLS epoch chain. Every
|
|
17
|
+
* peer holding the same ledger holds the same sequence, so the fold is
|
|
18
|
+
* deterministic without kubun imposing an order of its own.
|
|
19
|
+
*
|
|
20
|
+
* Kubun used to sort by the signed `ord` — an order the author asserts, and so
|
|
21
|
+
* one the author can backdate: a demoted admin could date an entry into the
|
|
22
|
+
* window where it still held the role. A position is assigned by the commit
|
|
23
|
+
* chain and cannot be claimed.
|
|
24
|
+
*
|
|
25
|
+
* Authority is NOT checked here: the caller filters unauthorized entries out
|
|
26
|
+
* first, because the reducer's `verifyAuthority` sees only the entry and the
|
|
27
|
+
* prefix an entry is judged against is a property of its slot.
|
|
28
|
+
*/ export function foldLedgerEntries(entries, anchor, reducer, onDrop) {
|
|
29
|
+
return foldLedger(entries, anchor, reducer, onDrop);
|
|
30
|
+
}
|