@kubun/plugin-p2p 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +3 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +105 -91
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +5 -1
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +61 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -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 +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +20 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +0 -6
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +17 -48
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +17 -16
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +1 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +439 -2
- package/lib/sync/authorize.d.ts +1 -1
- package/lib/sync/authorize.js +73 -1
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +3 -2
- package/lib/sync/errors.js +19 -1
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -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 +2 -2
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +1 -1
- package/lib/sync/peer-registry.js +107 -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 +3 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +11 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.js +21 -1
- package/package.json +47 -40
- 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/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.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
|
@@ -1 +1,183 @@
|
|
|
1
|
-
export const DEFAULT_BROADCAST_BATCH_CONFIG
|
|
1
|
+
export const DEFAULT_BROADCAST_BATCH_CONFIG = {
|
|
2
|
+
enabled: true,
|
|
3
|
+
windowMs: 500,
|
|
4
|
+
maxCount: 20,
|
|
5
|
+
maxBytes: 64 * 1024
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Build a per-target-group batch queue around a `scheduleBroadcast` hook.
|
|
9
|
+
*
|
|
10
|
+
* The `mutation:apply` broadcast variant carries `entries: Array<...>` —
|
|
11
|
+
* a queue flush emits ONE broadcast with the accumulated entries, not one
|
|
12
|
+
* broadcast per entry.
|
|
13
|
+
*
|
|
14
|
+
* Each enqueued entry's wire size is approximated via `JSON.stringify(entry).length`.
|
|
15
|
+
* The accumulated byte budget tracks per-batch payload size only — message
|
|
16
|
+
* envelope (`type`, `senderPeerDID`) overhead is intentionally ignored to keep
|
|
17
|
+
* accounting cheap and predictable.
|
|
18
|
+
*
|
|
19
|
+
* Sender-DID consistency: the queue tracks the `senderPeerDID` of the first
|
|
20
|
+
* entry in each batch. If a later enqueue arrives with a different sender
|
|
21
|
+
* (defensive — in practice senderPeerDID is constant per peer), the prior
|
|
22
|
+
* batch is flushed and a fresh batch starts. This preserves the invariant
|
|
23
|
+
* that every flushed `mutation:apply` message carries a single coherent
|
|
24
|
+
* `senderPeerDID`.
|
|
25
|
+
*/ export function createBroadcastQueue(params) {
|
|
26
|
+
const { config, scheduleBroadcast, logger } = params;
|
|
27
|
+
const queues = new Map();
|
|
28
|
+
let disposed = false;
|
|
29
|
+
function clearTimer(state) {
|
|
30
|
+
if (state.timer != null) {
|
|
31
|
+
clearTimeout(state.timer);
|
|
32
|
+
state.timer = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function flush(targetGroupID) {
|
|
36
|
+
const state = queues.get(targetGroupID);
|
|
37
|
+
if (state == null || state.entries.length === 0) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
clearTimer(state);
|
|
41
|
+
const entries = state.entries;
|
|
42
|
+
const senderPeerDID = state.senderPeerDID;
|
|
43
|
+
queues.delete(targetGroupID);
|
|
44
|
+
try {
|
|
45
|
+
scheduleBroadcast(targetGroupID, {
|
|
46
|
+
type: 'mutation:apply',
|
|
47
|
+
entries,
|
|
48
|
+
senderPeerDID
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
logger?.warn('broadcast-queue flush failed', {
|
|
52
|
+
targetGroupID,
|
|
53
|
+
entryCount: entries.length,
|
|
54
|
+
error: String(error)
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function startTimer(targetGroupID) {
|
|
59
|
+
const state = queues.get(targetGroupID);
|
|
60
|
+
if (state == null || state.timer != null) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const timer = setTimeout(()=>{
|
|
64
|
+
flush(targetGroupID);
|
|
65
|
+
}, config.windowMs);
|
|
66
|
+
// Don't hold the process open just for a pending broadcast batch.
|
|
67
|
+
// Node returns a Timeout object with `unref()`; browsers return a number.
|
|
68
|
+
const maybeUnref = timer.unref;
|
|
69
|
+
if (typeof maybeUnref === 'function') {
|
|
70
|
+
maybeUnref.call(timer);
|
|
71
|
+
}
|
|
72
|
+
state.timer = timer;
|
|
73
|
+
}
|
|
74
|
+
function appendToFreshBatch(targetGroupID, entry, entryBytes, senderPeerDID) {
|
|
75
|
+
const state = {
|
|
76
|
+
entries: [
|
|
77
|
+
entry
|
|
78
|
+
],
|
|
79
|
+
bytes: entryBytes,
|
|
80
|
+
senderPeerDID,
|
|
81
|
+
timer: null
|
|
82
|
+
};
|
|
83
|
+
queues.set(targetGroupID, state);
|
|
84
|
+
}
|
|
85
|
+
function enqueue(targetGroupID, entry, senderPeerDID) {
|
|
86
|
+
if (disposed) {
|
|
87
|
+
logger?.warn('broadcast-queue enqueue after dispose, dropping entry', {
|
|
88
|
+
targetGroupID,
|
|
89
|
+
docID: entry.docID
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Pass-through mode: emit each entry as its own single-entry broadcast.
|
|
94
|
+
if (!config.enabled) {
|
|
95
|
+
try {
|
|
96
|
+
scheduleBroadcast(targetGroupID, {
|
|
97
|
+
type: 'mutation:apply',
|
|
98
|
+
entries: [
|
|
99
|
+
entry
|
|
100
|
+
],
|
|
101
|
+
senderPeerDID
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger?.warn('broadcast-queue pass-through scheduleBroadcast failed', {
|
|
105
|
+
targetGroupID,
|
|
106
|
+
docID: entry.docID,
|
|
107
|
+
error: String(error)
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const entryBytes = JSON.stringify(entry).length;
|
|
113
|
+
// Oversize lone entry: skip the queue and ship as its own batch immediately.
|
|
114
|
+
// If a batch is already queued for this group, flush it first to preserve
|
|
115
|
+
// ordering (older entries ship before this lone giant).
|
|
116
|
+
if (entryBytes >= config.maxBytes) {
|
|
117
|
+
const existing = queues.get(targetGroupID);
|
|
118
|
+
if (existing != null && existing.entries.length > 0) {
|
|
119
|
+
flush(targetGroupID);
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
scheduleBroadcast(targetGroupID, {
|
|
123
|
+
type: 'mutation:apply',
|
|
124
|
+
entries: [
|
|
125
|
+
entry
|
|
126
|
+
],
|
|
127
|
+
senderPeerDID
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
logger?.warn('broadcast-queue oversize-entry scheduleBroadcast failed', {
|
|
131
|
+
targetGroupID,
|
|
132
|
+
docID: entry.docID,
|
|
133
|
+
error: String(error)
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let state = queues.get(targetGroupID);
|
|
139
|
+
// Sender mismatch: flush prior batch, start fresh.
|
|
140
|
+
if (state != null && state.senderPeerDID !== senderPeerDID) {
|
|
141
|
+
flush(targetGroupID);
|
|
142
|
+
state = undefined;
|
|
143
|
+
}
|
|
144
|
+
// Byte threshold: would adding this entry overflow? Flush first, start new batch.
|
|
145
|
+
if (state != null && state.bytes + entryBytes > config.maxBytes) {
|
|
146
|
+
flush(targetGroupID);
|
|
147
|
+
state = undefined;
|
|
148
|
+
}
|
|
149
|
+
if (state == null) {
|
|
150
|
+
appendToFreshBatch(targetGroupID, entry, entryBytes, senderPeerDID);
|
|
151
|
+
// Start the window timer for this new batch — only if we won't flush
|
|
152
|
+
// synchronously below for count reasons.
|
|
153
|
+
const fresh = queues.get(targetGroupID);
|
|
154
|
+
if (fresh != null && fresh.entries.length >= config.maxCount) {
|
|
155
|
+
flush(targetGroupID);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
startTimer(targetGroupID);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
state.entries.push(entry);
|
|
162
|
+
state.bytes += entryBytes;
|
|
163
|
+
// Count threshold: flush synchronously without waiting for the timer.
|
|
164
|
+
if (state.entries.length >= config.maxCount) {
|
|
165
|
+
flush(targetGroupID);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function dispose() {
|
|
169
|
+
if (disposed) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
disposed = true;
|
|
173
|
+
// Snapshot keys — flush mutates the map.
|
|
174
|
+
const targets = Array.from(queues.keys());
|
|
175
|
+
for (const targetGroupID of targets){
|
|
176
|
+
flush(targetGroupID);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
enqueue,
|
|
181
|
+
dispose
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -1 +1,179 @@
|
|
|
1
|
-
import{catalogMatchesDoc
|
|
1
|
+
import { catalogMatchesDoc, resolveAccessRule } from '@kubun/engine';
|
|
2
|
+
import { DocumentID } from '@kubun/id';
|
|
3
|
+
import { getGraphStore } from '@kubun/store-graph';
|
|
4
|
+
import { getP2PStore } from '@kubun/store-p2p';
|
|
5
|
+
import { computeBroadcastScopes } from './scope-resolver.js';
|
|
6
|
+
/**
|
|
7
|
+
* Default push-sync config. Disabled by default — push-sync is opt-in even
|
|
8
|
+
* when a hub is configured. Apps that want immediate broadcast on local
|
|
9
|
+
* authoring must explicitly set `pushSync: { enabled: true }`.
|
|
10
|
+
*/ export const DEFAULT_PUSH_SYNC_CONFIG = {
|
|
11
|
+
enabled: false
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Default rule used by the broadcast sender when the engine is not carrying
|
|
15
|
+
* an explicit server-side default access level. Kept conservative (only the
|
|
16
|
+
* owner receives) so undeclared models never accidentally fan out.
|
|
17
|
+
*/ const DEFAULT_ACCESS_LEVEL = {
|
|
18
|
+
read: 'only_owner',
|
|
19
|
+
write: 'only_owner'
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Subscribe to `engine:mutation:authored` and, for each locally-authored
|
|
23
|
+
* mutation, compute the set of MLS group scopes to broadcast to (via the
|
|
24
|
+
* pure `computeBroadcastScopes`) and fire per-scope
|
|
25
|
+
* `mutation:apply` broadcasts through the provided `scheduleBroadcast` hook.
|
|
26
|
+
*
|
|
27
|
+
* The subscriber swallows errors — a failure here must not propagate into the
|
|
28
|
+
* engine's event loop and stall other listeners. Errors are logged via the
|
|
29
|
+
* optional logger.
|
|
30
|
+
*
|
|
31
|
+
* Returns an unsubscribe function — the caller is responsible for invoking it
|
|
32
|
+
* on plugin dispose.
|
|
33
|
+
*/ export function wireBroadcastSender(params) {
|
|
34
|
+
const defaultAccessLevel = params.defaultAccessLevel ?? DEFAULT_ACCESS_LEVEL;
|
|
35
|
+
return params.eventBus.on('engine:mutation:authored', async (event)=>{
|
|
36
|
+
try {
|
|
37
|
+
await handleAuthoredMutation(event, {
|
|
38
|
+
...params,
|
|
39
|
+
defaultAccessLevel
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
params.logger?.warn('broadcast-sender handler failed', {
|
|
43
|
+
documentID: event.documentID,
|
|
44
|
+
error: String(error)
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async function handleAuthoredMutation(event, params) {
|
|
50
|
+
const graphStore = await getGraphStore(params.db);
|
|
51
|
+
const p2pStore = await getP2PStore(params.db);
|
|
52
|
+
// 1. Fetch document. If it no longer exists (unexpected, since the apply
|
|
53
|
+
// just completed) there is nothing to route — drop silently.
|
|
54
|
+
const doc = await graphStore.getDocument(DocumentID.fromString(event.documentID));
|
|
55
|
+
if (doc == null) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// 2. Resolve the effective read rule — broadcast scope follows read access.
|
|
59
|
+
const rule = await resolveAccessRule(doc, doc.model, doc.owner, 'read', {
|
|
60
|
+
getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>graphStore.getUserModelAccessDefault(ownerDID, modelID, permissionType),
|
|
61
|
+
isMemberOfAnyCircle: (viewerDID, circleIDs)=>p2pStore.isMemberOfAnyCircle(viewerDID, circleIDs),
|
|
62
|
+
isMemberOfAnyGroup: (viewerDID, groupIDs)=>p2pStore.isMemberOfAnyGroup(viewerDID, groupIDs),
|
|
63
|
+
getModelInterfaces: (modelID)=>graphStore.getModelInterfaces(modelID)
|
|
64
|
+
}, params.defaultAccessLevel);
|
|
65
|
+
// Short-circuit only_owner — scope resolver would also drop this, but we
|
|
66
|
+
// can avoid all the catalog lookups.
|
|
67
|
+
if (rule.level === 'only_owner') {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// 3. Enumerate sender's circles and catalog-match per circle. A sender's MLS
|
|
71
|
+
// group is a candidate broadcast scope iff at least one of the sender's
|
|
72
|
+
// circles within that group covers this document.
|
|
73
|
+
//
|
|
74
|
+
// Pre-fetch every catalog referenced across senderCircles in ONE batched
|
|
75
|
+
// query — replaces an O(circles × catalogs) sequential `getCatalog` loop.
|
|
76
|
+
// catalogMatchesDoc reads are then run in parallel since they have no
|
|
77
|
+
// inter-iteration ordering.
|
|
78
|
+
const senderCircles = await p2pStore.getCirclesForMember(params.selfDID);
|
|
79
|
+
const catalogIDSet = new Set();
|
|
80
|
+
for (const circle of senderCircles){
|
|
81
|
+
for (const catalogID of circle.catalog_ids ?? []){
|
|
82
|
+
catalogIDSet.add(catalogID);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const catalogsByID = catalogIDSet.size > 0 ? await graphStore.getCatalogs(Array.from(catalogIDSet)) : new Map();
|
|
86
|
+
const matchInputs = [];
|
|
87
|
+
for (const circle of senderCircles){
|
|
88
|
+
for (const catalogID of circle.catalog_ids ?? []){
|
|
89
|
+
const catalog = catalogsByID.get(catalogID);
|
|
90
|
+
if (catalog == null) continue;
|
|
91
|
+
matchInputs.push({
|
|
92
|
+
groupID: circle.group_id,
|
|
93
|
+
criteria: catalog.filter_criteria
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const matchResults = await Promise.all(matchInputs.map(async (input)=>({
|
|
98
|
+
groupID: input.groupID,
|
|
99
|
+
matched: await catalogMatchesDoc(input.criteria, doc, p2pStore)
|
|
100
|
+
})));
|
|
101
|
+
const matchedGroupIDs = new Set();
|
|
102
|
+
for (const r of matchResults){
|
|
103
|
+
if (r.matched) matchedGroupIDs.add(r.groupID);
|
|
104
|
+
}
|
|
105
|
+
const senderGroupIDs = Array.from(matchedGroupIDs);
|
|
106
|
+
if (senderGroupIDs.length === 0) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// 4. Build the ResolvedAccessRule expected by computeBroadcastScopes.
|
|
110
|
+
const allowedGroupIDs = new Set(rule.allowedGroups ?? []);
|
|
111
|
+
// Single batched query for allowedCircles → parent group IDs. Replaces a
|
|
112
|
+
// per-circle `getCircle` loop.
|
|
113
|
+
const allowedCircleParentGroupIDs = new Set();
|
|
114
|
+
if (rule.allowedCircles != null && rule.allowedCircles.length > 0) {
|
|
115
|
+
const circlesByID = await p2pStore.getCircles(rule.allowedCircles);
|
|
116
|
+
for (const circle of circlesByID.values()){
|
|
117
|
+
if (circle.group_id != null) {
|
|
118
|
+
allowedCircleParentGroupIDs.add(circle.group_id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Single batched query for allowedDIDs → groups. Replaces a per-DID
|
|
123
|
+
// `getGroupsForMember` loop.
|
|
124
|
+
const allowedDIDGroupIDs = new Set();
|
|
125
|
+
if (rule.allowedDIDs != null && rule.allowedDIDs.length > 0) {
|
|
126
|
+
const groupsByDID = await p2pStore.getGroupsForMembers(rule.allowedDIDs);
|
|
127
|
+
for (const groups of groupsByDID.values()){
|
|
128
|
+
for (const g of groups){
|
|
129
|
+
allowedDIDGroupIDs.add(g.id);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const resolvedRule = {
|
|
134
|
+
level: rule.level,
|
|
135
|
+
allowedGroupIDs,
|
|
136
|
+
allowedCircleParentGroupIDs,
|
|
137
|
+
allowedDIDGroupIDs
|
|
138
|
+
};
|
|
139
|
+
// 5. Resolve final broadcast scopes.
|
|
140
|
+
const scopes = computeBroadcastScopes({
|
|
141
|
+
resolvedRule,
|
|
142
|
+
senderGroupIDs
|
|
143
|
+
});
|
|
144
|
+
if (scopes.length === 0) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// 6. Per scope — broadcast_log dedup (returns false if already recorded)
|
|
148
|
+
// then enqueue into the per-target-group batch queue. Dedup MUST run before
|
|
149
|
+
// enqueue so duplicate entries never enter the queue.
|
|
150
|
+
//
|
|
151
|
+
// Record-before-send: a recorded entry whose queue.enqueue throws will
|
|
152
|
+
// not be retried. This is at-most-once delivery by design; missing
|
|
153
|
+
// broadcasts are recovered via merkle-pull, not by replaying the
|
|
154
|
+
// authored event.
|
|
155
|
+
//
|
|
156
|
+
// This loop stays SEQUENTIAL (no Promise.all). `recordBroadcast` ordering
|
|
157
|
+
// matters for the at-most-once invariant, and the queue's per-group state
|
|
158
|
+
// machines have not been audited for concurrent enqueue safety. The earlier
|
|
159
|
+
// catalog-match/getCircles/getGroupsForMembers calls were parallelized
|
|
160
|
+
// because they are pure DB reads with no inter-iteration ordering — this
|
|
161
|
+
// path performs side-effecting writes and external dispatch.
|
|
162
|
+
for (const groupID of scopes){
|
|
163
|
+
const recorded = await p2pStore.recordBroadcast({
|
|
164
|
+
docID: event.documentID,
|
|
165
|
+
version: event.version,
|
|
166
|
+
mlsGroupID: groupID
|
|
167
|
+
});
|
|
168
|
+
if (!recorded) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const entry = {
|
|
172
|
+
mutationJWT: event.mutationJWT,
|
|
173
|
+
docID: event.documentID,
|
|
174
|
+
version: event.version,
|
|
175
|
+
modelID: event.modelID
|
|
176
|
+
};
|
|
177
|
+
params.queue.enqueue(groupID, entry, params.selfDID);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -1 +1,68 @@
|
|
|
1
|
-
import{getGraphStore
|
|
1
|
+
import { getGraphStore } from '@kubun/store-graph';
|
|
2
|
+
import { getP2PStore } from '@kubun/store-p2p';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve catalog criteria to sync-compatible scopes.
|
|
5
|
+
*
|
|
6
|
+
* Takes catalog IDs, resolves their criteria, and returns:
|
|
7
|
+
* - modelIDs: union of all model filters from all catalogs
|
|
8
|
+
* - owners: union of all owner filters (explicit + circle members)
|
|
9
|
+
* - requiredClusterIDs: cluster IDs for all required models
|
|
10
|
+
* - missingClusterIDs: cluster IDs for models not in knownModelIDs
|
|
11
|
+
*/ export async function resolveCatalogSyncScopes(db, catalogIDs, knownModelIDs) {
|
|
12
|
+
const [graphStore, p2pStore] = await Promise.all([
|
|
13
|
+
getGraphStore(db),
|
|
14
|
+
getP2PStore(db)
|
|
15
|
+
]);
|
|
16
|
+
const allModelIDs = new Set();
|
|
17
|
+
const allOwners = new Set();
|
|
18
|
+
let hasOwnerFilter = false;
|
|
19
|
+
for (const catalogID of catalogIDs){
|
|
20
|
+
const scope = await graphStore.resolveCatalogScope(catalogID);
|
|
21
|
+
if (scope.models != null) {
|
|
22
|
+
for (const m of scope.models){
|
|
23
|
+
allModelIDs.add(m);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (scope.owners != null) {
|
|
27
|
+
hasOwnerFilter = true;
|
|
28
|
+
for (const o of scope.owners){
|
|
29
|
+
allOwners.add(o);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Resolve circle-based owners via p2p store
|
|
33
|
+
const catalog = await graphStore.getCatalog(catalogID);
|
|
34
|
+
if (catalog != null) {
|
|
35
|
+
const criteria = catalog.filter_criteria;
|
|
36
|
+
if (criteria.circles != null && criteria.circles.length > 0) {
|
|
37
|
+
hasOwnerFilter = true;
|
|
38
|
+
for (const circleID of criteria.circles){
|
|
39
|
+
const members = await p2pStore.listCircleMembers(circleID);
|
|
40
|
+
for (const member of members){
|
|
41
|
+
allOwners.add(member.member_did);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const modelIDs = Array.from(allModelIDs);
|
|
48
|
+
const owners = hasOwnerFilter ? Array.from(allOwners) : undefined;
|
|
49
|
+
// Resolve clusters for all models in a single pass
|
|
50
|
+
const knownSet = new Set(knownModelIDs ?? []);
|
|
51
|
+
const requiredClusterIDs = new Set();
|
|
52
|
+
const missingClusterIDs = new Set();
|
|
53
|
+
await Promise.all(modelIDs.map(async (modelID)=>{
|
|
54
|
+
const clusterID = await graphStore.getClusterForModel(modelID);
|
|
55
|
+
if (clusterID != null) {
|
|
56
|
+
requiredClusterIDs.add(clusterID);
|
|
57
|
+
if (!knownSet.has(modelID)) {
|
|
58
|
+
missingClusterIDs.add(clusterID);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}));
|
|
62
|
+
return {
|
|
63
|
+
modelIDs,
|
|
64
|
+
owners,
|
|
65
|
+
requiredClusterIDs: Array.from(requiredClusterIDs),
|
|
66
|
+
missingClusterIDs: Array.from(missingClusterIDs)
|
|
67
|
+
};
|
|
68
|
+
}
|
package/lib/sync/errors.d.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* so server-side logging and tests can assert the reason.
|
|
6
6
|
*/
|
|
7
7
|
export declare class SyncAccessDeniedError extends Error {
|
|
8
|
-
|
|
9
|
-
readonly reason: string;
|
|
8
|
+
#private;
|
|
10
9
|
constructor(reason: string, message?: string);
|
|
10
|
+
get code(): 'KB08';
|
|
11
|
+
get reason(): string;
|
|
11
12
|
}
|
package/lib/sync/errors.js
CHANGED
|
@@ -1 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by the sync handlers when a request is denied before any store
|
|
3
|
+
* read — for example a forged subject, an unauthorized scope, or a
|
|
4
|
+
* disallowed direction. Tagged with the protocol-level error code `KB08`
|
|
5
|
+
* so server-side logging and tests can assert the reason.
|
|
6
|
+
*/ export class SyncAccessDeniedError extends Error {
|
|
7
|
+
#reason;
|
|
8
|
+
constructor(reason, message){
|
|
9
|
+
super(message ?? `Sync access denied: ${reason}`);
|
|
10
|
+
this.name = 'SyncAccessDeniedError';
|
|
11
|
+
this.#reason = reason;
|
|
12
|
+
}
|
|
13
|
+
get code() {
|
|
14
|
+
return 'KB08';
|
|
15
|
+
}
|
|
16
|
+
get reason() {
|
|
17
|
+
return this.#reason;
|
|
18
|
+
}
|
|
19
|
+
}
|