@kubun/plugin-p2p 0.8.3 → 0.10.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.d.ts +4 -0
- package/lib/context/delegation.js +1 -0
- package/lib/context/group.js +1 -1
- package/lib/context/hub.d.ts +4 -0
- package/lib/context/hub.js +1 -0
- package/lib/context/join.js +1 -1
- package/lib/context/types.d.ts +28 -2
- package/lib/groups/broadcast-service.d.ts +130 -17
- package/lib/groups/broadcast-service.js +1 -1
- package/lib/groups/broadcast.d.ts +162 -5
- package/lib/groups/broadcast.js +1 -1
- package/lib/groups/events.d.ts +26 -5
- package/lib/groups/events.js +1 -1
- package/lib/groups/group-handle-registry.d.ts +90 -0
- package/lib/groups/group-handle-registry.js +1 -0
- package/lib/groups/invite-payload.d.ts +21 -1
- package/lib/groups/join-utils.d.ts +68 -2
- package/lib/groups/join-utils.js +1 -1
- package/lib/groups/manager.d.ts +26 -18
- package/lib/groups/manager.js +1 -1
- package/lib/groups/mls-codec.d.ts +7 -0
- package/lib/groups/mls-codec.js +1 -0
- package/lib/groups/mls-encryptor.d.ts +25 -0
- package/lib/groups/mls-encryptor.js +1 -0
- package/lib/groups/store-received-grant.d.ts +47 -0
- package/lib/groups/store-received-grant.js +1 -0
- package/lib/groups/store-received-revocation.d.ts +46 -0
- package/lib/groups/store-received-revocation.js +1 -0
- package/lib/groups/wire-frame.d.ts +32 -0
- package/lib/groups/wire-frame.js +1 -0
- package/lib/hub/connection-pool.d.ts +24 -0
- package/lib/hub/connection-pool.js +1 -1
- package/lib/hub/did-observing-transport.d.ts +64 -0
- package/lib/hub/did-observing-transport.js +1 -0
- package/lib/hub/errors.d.ts +28 -0
- package/lib/hub/errors.js +1 -0
- package/lib/hub/forward-remote-broadcast.d.ts +15 -0
- package/lib/hub/forward-remote-broadcast.js +1 -0
- package/lib/hub/group-channel.d.ts +37 -21
- package/lib/hub/group-channel.js +1 -1
- package/lib/hub/http-client.d.ts +17 -0
- package/lib/hub/http-client.js +1 -0
- package/lib/hub/hub-connection.d.ts +96 -0
- package/lib/hub/hub-connection.js +1 -0
- package/lib/hub/manager.d.ts +117 -0
- package/lib/hub/manager.js +1 -0
- package/lib/hub/receive-handler.d.ts +3 -6
- package/lib/hub/receive-handler.js +1 -1
- package/lib/hub/relay-manager.d.ts +92 -2
- package/lib/hub/relay-manager.js +1 -1
- package/lib/hub/send-handler.d.ts +28 -16
- package/lib/hub/send-handler.js +1 -1
- package/lib/hub/tunnel-inbox.d.ts +20 -0
- package/lib/hub/tunnel-inbox.js +1 -0
- package/lib/hub/wait-for-gate.d.ts +14 -0
- package/lib/hub/wait-for-gate.js +1 -0
- package/lib/hub/wiring.d.ts +85 -0
- package/lib/hub/wiring.js +1 -0
- package/lib/index.d.ts +102 -7
- package/lib/index.js +1 -1
- package/lib/schema.d.ts +2 -2
- package/lib/schema.js +113 -21
- package/lib/sync/broadcast-queue.d.ts +59 -0
- package/lib/sync/broadcast-queue.js +1 -0
- package/lib/sync/broadcast-sender.d.ts +52 -0
- package/lib/sync/broadcast-sender.js +1 -0
- package/lib/sync/forwarder.d.ts +97 -0
- package/lib/sync/forwarder.js +1 -0
- package/lib/sync/handlers.d.ts +20 -1
- package/lib/sync/handlers.js +1 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
- package/lib/sync/hub-tunnel-sync-listener.js +1 -0
- package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
- package/lib/sync/hub-tunnel-sync-provider.js +1 -0
- package/lib/sync/merkle-apply.d.ts +41 -0
- package/lib/sync/merkle-apply.js +1 -1
- package/lib/sync/merkle-channel.d.ts +15 -0
- package/lib/sync/merkle-channel.js +1 -1
- package/lib/sync/receive-access-gate.d.ts +36 -0
- package/lib/sync/receive-access-gate.js +1 -0
- package/lib/sync/scope-resolver.d.ts +32 -0
- package/lib/sync/scope-resolver.js +1 -0
- package/lib/sync/sync-manager.d.ts +10 -0
- package/lib/sync/sync-manager.js +1 -1
- package/lib/types.d.ts +151 -1
- package/lib/util/mutex.d.ts +4 -0
- package/lib/util/mutex.js +1 -0
- package/package.json +40 -37
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { GroupBroadcastMessage, MutationApplyEntry } from '../groups/broadcast.js';
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for the per-target-group batch broadcast queue.
|
|
5
|
+
*
|
|
6
|
+
* Each queue accumulates {@link MutationApplyEntry} items destined for the
|
|
7
|
+
* same MLS group, then flushes them as a single `mutation:apply` broadcast
|
|
8
|
+
* when ANY threshold trips:
|
|
9
|
+
* - `windowMs` elapses since the first entry of the current batch
|
|
10
|
+
* - the queue accumulates `maxCount` entries
|
|
11
|
+
* - the accumulated byte size hits `maxBytes`
|
|
12
|
+
*
|
|
13
|
+
* When `enabled` is false, the queue degrades to pass-through: each enqueue
|
|
14
|
+
* fires `scheduleBroadcast` immediately as a single-entry batch.
|
|
15
|
+
*/
|
|
16
|
+
export type BroadcastBatchConfig = {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
windowMs: number;
|
|
19
|
+
maxCount: number;
|
|
20
|
+
maxBytes: number;
|
|
21
|
+
};
|
|
22
|
+
export declare const DEFAULT_BROADCAST_BATCH_CONFIG: BroadcastBatchConfig;
|
|
23
|
+
export type BroadcastQueueParams = {
|
|
24
|
+
config: BroadcastBatchConfig;
|
|
25
|
+
scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
|
|
26
|
+
logger?: Logger;
|
|
27
|
+
};
|
|
28
|
+
export type BroadcastQueue = {
|
|
29
|
+
/**
|
|
30
|
+
* Enqueue a single mutation entry for the given target group, attributed to
|
|
31
|
+
* `senderPeerDID`. May trigger an immediate flush if a threshold is reached.
|
|
32
|
+
*/
|
|
33
|
+
enqueue(targetGroupID: string, entry: MutationApplyEntry, senderPeerDID: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* Cancel all pending timers and synchronously flush remaining entries
|
|
36
|
+
* via `scheduleBroadcast`. Safe to call multiple times.
|
|
37
|
+
*/
|
|
38
|
+
dispose(): void;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Build a per-target-group batch queue around a `scheduleBroadcast` hook.
|
|
42
|
+
*
|
|
43
|
+
* The `mutation:apply` broadcast variant carries `entries: Array<...>` —
|
|
44
|
+
* a queue flush emits ONE broadcast with the accumulated entries, not one
|
|
45
|
+
* broadcast per entry.
|
|
46
|
+
*
|
|
47
|
+
* Each enqueued entry's wire size is approximated via `JSON.stringify(entry).length`.
|
|
48
|
+
* The accumulated byte budget tracks per-batch payload size only — message
|
|
49
|
+
* envelope (`type`, `senderPeerDID`) overhead is intentionally ignored to keep
|
|
50
|
+
* accounting cheap and predictable.
|
|
51
|
+
*
|
|
52
|
+
* Sender-DID consistency: the queue tracks the `senderPeerDID` of the first
|
|
53
|
+
* entry in each batch. If a later enqueue arrives with a different sender
|
|
54
|
+
* (defensive — in practice senderPeerDID is constant per peer), the prior
|
|
55
|
+
* batch is flushed and a fresh batch starts. This preserves the invariant
|
|
56
|
+
* that every flushed `mutation:apply` message carries a single coherent
|
|
57
|
+
* `senderPeerDID`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createBroadcastQueue(params: BroadcastQueueParams): BroadcastQueue;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_BROADCAST_BATCH_CONFIG={enabled:!0,windowMs:500,maxCount:20,maxBytes:65536};export function createBroadcastQueue(e){let{config:t,scheduleBroadcast:r,logger:n}=e,l=new Map,o=!1;function u(e){let t=l.get(e);if(null==t||0===t.entries.length)return;null!=t.timer&&(clearTimeout(t.timer),t.timer=null);let o=t.entries,u=t.senderPeerDID;l.delete(e);try{r(e,{type:"mutation:apply",entries:o,senderPeerDID:u})}catch(t){n?.warn("broadcast-queue flush failed",{targetGroupID:e,entryCount:o.length,error:String(t)})}}return{enqueue:function(e,i,a){if(o)return void n?.warn("broadcast-queue enqueue after dispose, dropping entry",{targetGroupID:e,docID:i.docID});if(!t.enabled){try{r(e,{type:"mutation:apply",entries:[i],senderPeerDID:a})}catch(t){n?.warn("broadcast-queue pass-through scheduleBroadcast failed",{targetGroupID:e,docID:i.docID,error:String(t)})}return}let s=JSON.stringify(i).length;if(s>=t.maxBytes){let t=l.get(e);null!=t&&t.entries.length>0&&u(e);try{r(e,{type:"mutation:apply",entries:[i],senderPeerDID:a})}catch(t){n?.warn("broadcast-queue oversize-entry scheduleBroadcast failed",{targetGroupID:e,docID:i.docID,error:String(t)})}return}let d=l.get(e);if(null!=d&&d.senderPeerDID!==a&&(u(e),d=void 0),null!=d&&d.bytes+s>t.maxBytes&&(u(e),d=void 0),null==d){l.set(e,{entries:[i],bytes:s,senderPeerDID:a,timer:null});let r=l.get(e);return null!=r&&r.entries.length>=t.maxCount?void u(e):void!function(e){let r=l.get(e);if(null==r||null!=r.timer)return;let n=setTimeout(()=>{u(e)},t.windowMs),o=n.unref;"function"==typeof o&&o.call(n),r.timer=n}(e)}d.entries.push(i),d.bytes+=s,d.entries.length>=t.maxCount&&u(e)},dispose:function(){if(!o)for(let e of(o=!0,Array.from(l.keys())))u(e)}}}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { KubunDB } from '@kubun/db';
|
|
2
|
+
import { type DefaultAccessLevel, type EngineEventBus, type EngineEvents } from '@kubun/engine';
|
|
3
|
+
import type { Logger } from '@kubun/logger';
|
|
4
|
+
import type { BroadcastQueue } from './broadcast-queue.js';
|
|
5
|
+
/**
|
|
6
|
+
* Push-sync runtime config. Gates the local `engine:mutation:authored`
|
|
7
|
+
* subscriber that drives `mutation:apply` broadcasts. When disabled, no
|
|
8
|
+
* `mutation:apply` broadcasts leave this peer; merkle-pull continues to
|
|
9
|
+
* operate; the receive path is unaffected.
|
|
10
|
+
*/
|
|
11
|
+
export type PushSyncConfig = {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Default push-sync config. Disabled by default — push-sync is opt-in even
|
|
16
|
+
* when a hub is configured. Apps that want immediate broadcast on local
|
|
17
|
+
* authoring must explicitly set `pushSync: { enabled: true }`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DEFAULT_PUSH_SYNC_CONFIG: PushSyncConfig;
|
|
20
|
+
export type BroadcastSenderParams = {
|
|
21
|
+
db: KubunDB;
|
|
22
|
+
eventBus: EngineEventBus<EngineEvents>;
|
|
23
|
+
selfDID: string;
|
|
24
|
+
/**
|
|
25
|
+
* Per-target-group batch queue. The sender enqueues per-scope entries here
|
|
26
|
+
* rather than calling `scheduleBroadcast` directly — the queue accumulates
|
|
27
|
+
* entries and flushes them as a single `mutation:apply` broadcast per
|
|
28
|
+
* target group when window/count/byte thresholds trip.
|
|
29
|
+
*/
|
|
30
|
+
queue: BroadcastQueue;
|
|
31
|
+
logger?: Logger;
|
|
32
|
+
/**
|
|
33
|
+
* Server default access level. Used when neither the document nor any user
|
|
34
|
+
* model default resolves a rule. Defaults to `only_owner` for both
|
|
35
|
+
* permissions (no fan-out for undeclared models).
|
|
36
|
+
*/
|
|
37
|
+
defaultAccessLevel?: DefaultAccessLevel;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Subscribe to `engine:mutation:authored` and, for each locally-authored
|
|
41
|
+
* mutation, compute the set of MLS group scopes to broadcast to (via the
|
|
42
|
+
* pure `computeBroadcastScopes`) and fire per-scope
|
|
43
|
+
* `mutation:apply` broadcasts through the provided `scheduleBroadcast` hook.
|
|
44
|
+
*
|
|
45
|
+
* The subscriber swallows errors — a failure here must not propagate into the
|
|
46
|
+
* engine's event loop and stall other listeners. Errors are logged via the
|
|
47
|
+
* optional logger.
|
|
48
|
+
*
|
|
49
|
+
* Returns an unsubscribe function — the caller is responsible for invoking it
|
|
50
|
+
* on plugin dispose.
|
|
51
|
+
*/
|
|
52
|
+
export declare function wireBroadcastSender(params: BroadcastSenderParams): () => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{catalogMatchesDoc as e,resolveAccessRule as r}from"@kubun/engine";import{DocumentID as t}from"@kubun/id";import{getGraphStore as o}from"@kubun/store-graph";import{getP2PStore as l}from"@kubun/store-p2p";import{computeBroadcastScopes as a}from"./scope-resolver.js";export const DEFAULT_PUSH_SYNC_CONFIG={enabled:!1};let n={read:"only_owner",write:"only_owner"};export function wireBroadcastSender(e){let r=e.defaultAccessLevel??n;return e.eventBus.on("engine:mutation:authored",async t=>{try{await i(t,{...e,defaultAccessLevel:r})}catch(r){e.logger?.warn("broadcast-sender handler failed",{documentID:t.documentID,error:String(r)})}})}async function i(n,i){let s=await o(i.db),d=await l(i.db),u=await s.getDocument(t.fromString(n.documentID));if(null==u)return;let f=await r(u,u.model,u.owner,"read",{getUserModelAccessDefault:(e,r,t)=>s.getUserModelAccessDefault(e,r,t),isMemberOfAnyCircle:(e,r)=>d.isMemberOfAnyCircle(e,r),isMemberOfAnyGroup:(e,r)=>d.isMemberOfAnyGroup(e,r),getModelInterfaces:e=>s.getModelInterfaces(e)},i.defaultAccessLevel);if("only_owner"===f.level)return;let c=await d.getCirclesForMember(i.selfDID),m=new Set;for(let e of c)for(let r of e.catalog_ids??[])m.add(r);let w=m.size>0?await s.getCatalogs(Array.from(m)):new Map,g=[];for(let e of c)for(let r of e.catalog_ids??[]){let t=w.get(r);null!=t&&g.push({groupID:e.group_id,criteria:t.filter_criteria})}let D=await Promise.all(g.map(async r=>({groupID:r.groupID,matched:await e(r.criteria,u,d)}))),p=new Set;for(let e of D)e.matched&&p.add(e.groupID);let I=Array.from(p);if(0===I.length)return;let b=new Set(f.allowedGroups??[]),v=new Set;if(null!=f.allowedCircles&&f.allowedCircles.length>0)for(let e of(await d.getCircles(f.allowedCircles)).values())null!=e.group_id&&v.add(e.group_id);let y=new Set;if(null!=f.allowedDIDs&&f.allowedDIDs.length>0)for(let e of(await d.getGroupsForMembers(f.allowedDIDs)).values())for(let r of e)y.add(r.id);let _=a({resolvedRule:{level:f.level,allowedGroupIDs:b,allowedCircleParentGroupIDs:v,allowedDIDGroupIDs:y},senderGroupIDs:I});if(0!==_.length)for(let e of _){if(!await d.recordBroadcast({docID:n.documentID,version:n.version,mlsGroupID:e}))continue;let r={mutationJWT:n.mutationJWT,docID:n.documentID,version:n.version,modelID:n.modelID};i.queue.enqueue(e,r,i.selfDID)}}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type DefaultAccessLevel } from '@kubun/engine';
|
|
2
|
+
import type { Logger } from '@kubun/logger';
|
|
3
|
+
import type { DocumentNode } from '@kubun/protocol';
|
|
4
|
+
import type { GraphStoreAPI } from '@kubun/store-graph';
|
|
5
|
+
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
6
|
+
import type { GroupBroadcastMessage, MutationApplyEntry } from '../groups/broadcast.js';
|
|
7
|
+
export declare function rethrowIfProgrammerError(error: unknown): void;
|
|
8
|
+
/**
|
|
9
|
+
* Context passed to a {@link ForwardFilter} for each candidate forwarding scope.
|
|
10
|
+
*
|
|
11
|
+
* The filter is invoked once per surviving (catalog ∧ access ∧ not-loopback)
|
|
12
|
+
* candidate group. The filter's role is to NARROW — gates are applied first,
|
|
13
|
+
* the filter only ever further reduces the forwarded set. Returning `false`
|
|
14
|
+
* skips the candidate; returning `true` (or no filter at all) forwards it.
|
|
15
|
+
*/
|
|
16
|
+
export type ForwardContext = {
|
|
17
|
+
docID: string;
|
|
18
|
+
modelID: string;
|
|
19
|
+
ownerDID: string;
|
|
20
|
+
/** MLS group the broadcast was received from. Excluded from forwarding. */
|
|
21
|
+
sourceGroupID: string;
|
|
22
|
+
/** Candidate MLS group ID to forward to. */
|
|
23
|
+
candidateGroupID: string;
|
|
24
|
+
/** Post-apply document state. */
|
|
25
|
+
postState: DocumentNode;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Per-candidate filter callback. May be sync or async.
|
|
29
|
+
*
|
|
30
|
+
* Awaited per candidate — slow filters serialize forwarding within a single
|
|
31
|
+
* broadcast batch. Filters MUST NOT throw; throwing is treated as `false`
|
|
32
|
+
* (skip) and logged.
|
|
33
|
+
*/
|
|
34
|
+
export type ForwardFilter = (ctx: ForwardContext) => boolean | Promise<boolean>;
|
|
35
|
+
/**
|
|
36
|
+
* Forwarding configuration.
|
|
37
|
+
* - `false` (default) — forwarding disabled. Hot-path has no extra DB
|
|
38
|
+
* lookups and no extra awaits in the receive path.
|
|
39
|
+
* - `true` — forward to every candidate group that passes catalog + access
|
|
40
|
+
* gates and is not the source group.
|
|
41
|
+
* - {@link ForwardFilter} — same as `true`, plus per-candidate narrowing.
|
|
42
|
+
*/
|
|
43
|
+
export type ForwardingConfig = boolean | ForwardFilter;
|
|
44
|
+
export type EvaluateAndForwardParams = {
|
|
45
|
+
/** Original broadcast entry being considered for forwarding. */
|
|
46
|
+
entry: MutationApplyEntry;
|
|
47
|
+
/** Post-apply document state — required for catalog match + filter context. */
|
|
48
|
+
postState: DocumentNode;
|
|
49
|
+
/** MLS group the broadcast was received from — never a forwarding target. */
|
|
50
|
+
sourceGroupID: string;
|
|
51
|
+
/** Bridging peer's own DID — used as `senderPeerDID` on forwarded broadcasts. */
|
|
52
|
+
selfDID: string;
|
|
53
|
+
p2pStore: P2PStoreAPI;
|
|
54
|
+
graphStore: GraphStoreAPI;
|
|
55
|
+
defaultAccessLevel: DefaultAccessLevel;
|
|
56
|
+
/** See {@link ForwardingConfig}. `false` short-circuits before any work. */
|
|
57
|
+
forwarding: ForwardingConfig;
|
|
58
|
+
/**
|
|
59
|
+
* Hub forwarding hook. Same shape as `HubWiring.scheduleBroadcast`. Called
|
|
60
|
+
* once per surviving candidate with a single-entry `mutation:apply`
|
|
61
|
+
* broadcast. Bypasses the batch queue intentionally — see
|
|
62
|
+
* {@link evaluateAndForward} for rationale.
|
|
63
|
+
*/
|
|
64
|
+
scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
|
|
65
|
+
logger?: Logger;
|
|
66
|
+
};
|
|
67
|
+
export type EvaluateAndForwardResult = {
|
|
68
|
+
/** Number of candidate groups the entry was forwarded to. */
|
|
69
|
+
forwarded: number;
|
|
70
|
+
/** Number of candidate groups skipped (filter returned false, catalog miss, dedup). */
|
|
71
|
+
skipped: number;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Evaluate forwarding candidates for a single mutation:apply entry the local
|
|
75
|
+
* peer has just applied, and forward to each surviving candidate group via
|
|
76
|
+
* `scheduleBroadcast`.
|
|
77
|
+
*
|
|
78
|
+
* Pipeline (per candidate group):
|
|
79
|
+
* 1. Source group exclusion — `sourceGroupID` is filtered out before any check.
|
|
80
|
+
* 2. Access gate — `computeBroadcastScopes` against the post-apply rule.
|
|
81
|
+
* 3. Catalog gate — at least one circle the local peer belongs to in the
|
|
82
|
+
* candidate group must have a catalog whose `filter_criteria` matches the
|
|
83
|
+
* document.
|
|
84
|
+
* 4. Filter (when `forwarding` is a function) — narrows further.
|
|
85
|
+
* 5. Dedup — `recordBroadcast` BEFORE schedule. False return → skip silently.
|
|
86
|
+
* 6. Schedule — single-entry `mutation:apply` broadcast with
|
|
87
|
+
* `senderPeerDID = selfDID`.
|
|
88
|
+
*
|
|
89
|
+
* Per-candidate errors are isolated: one bad candidate logs + continues; the
|
|
90
|
+
* rest still get evaluated. The function never throws.
|
|
91
|
+
*
|
|
92
|
+
* Routing decision: each candidate gets its OWN single-entry broadcast (no
|
|
93
|
+
* batch queue). The queue's `senderPeerDID`-mismatch flush conflicts
|
|
94
|
+
* with mixing locally-authored and forwarded entries in one batch — deferring
|
|
95
|
+
* batched-forwarding keeps the receive path simple.
|
|
96
|
+
*/
|
|
97
|
+
export declare function evaluateAndForward(params: EvaluateAndForwardParams): Promise<EvaluateAndForwardResult>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{catalogMatchesDoc as r,resolveAccessRule as e}from"@kubun/engine";import{computeBroadcastScopes as o}from"./scope-resolver.js";export function rethrowIfProgrammerError(r){if(r instanceof SyntaxError||r instanceof TypeError||r instanceof ReferenceError||r instanceof RangeError)throw r}export async function evaluateAndForward(t){let a,l;if(!1===t.forwarding)return{forwarded:0,skipped:0};let i=!0===t.forwarding?()=>!0:t.forwarding,n=(await t.p2pStore.getGroupsForMember(t.selfDID)).map(r=>r.id).filter(r=>r!==t.sourceGroupID);if(0===n.length)return{forwarded:0,skipped:0};try{a=await e(t.postState,t.postState.model,t.postState.owner,"read",{getUserModelAccessDefault:(r,e,o)=>t.graphStore.getUserModelAccessDefault(r,e,o),isMemberOfAnyCircle:(r,e)=>t.p2pStore.isMemberOfAnyCircle(r,e),isMemberOfAnyGroup:(r,e)=>t.p2pStore.isMemberOfAnyGroup(r,e),getModelInterfaces:r=>t.graphStore.getModelInterfaces(r)},t.defaultAccessLevel)}catch(r){return rethrowIfProgrammerError(r),t.logger?.warn("forwarder: resolveAccessRule failed, skipping forward",{docID:t.entry.docID,version:t.entry.version,sourceGroupID:t.sourceGroupID,error:String(r)}),{forwarded:0,skipped:0}}let s=new Set(a.allowedGroups??[]),f=new Set;if(null!=a.allowedCircles&&a.allowedCircles.length>0)try{for(let r of(await t.p2pStore.getCircles(a.allowedCircles)).values())null!=r.group_id&&f.add(r.group_id)}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCircles failed, skipping allowedCircles",{count:a.allowedCircles.length,error:String(r)})}let d=new Set;if(null!=a.allowedDIDs&&a.allowedDIDs.length>0)try{for(let r of(await t.p2pStore.getGroupsForMembers(a.allowedDIDs)).values())for(let e of r)d.add(e.id)}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getGroupsForMembers failed, skipping allowedDIDs",{count:a.allowedDIDs.length,error:String(r)})}let p=o({resolvedRule:{level:a.level,allowedGroupIDs:s,allowedCircleParentGroupIDs:f,allowedDIDGroupIDs:d},senderGroupIDs:n});if(0===p.length)return{forwarded:0,skipped:0};try{l=await t.p2pStore.getCirclesForMember(t.selfDID)}catch(r){return rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCirclesForMember failed",{selfDID:t.selfDID,error:String(r)}),{forwarded:0,skipped:0}}let c=new Set(p),g=new Map;for(let r of l){if(!c.has(r.group_id))continue;let e=g.get(r.group_id);null!=e?e.push(r):g.set(r.group_id,[r])}let u=new Set;for(let r of g.values())for(let e of r)for(let r of e.catalog_ids??[])u.add(r);let w=new Map;if(u.size>0)try{w=await t.graphStore.getCatalogs(Array.from(u))}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCatalogs failed, treating all as missing",{count:u.size,error:String(r)})}let D=0,I=0;for(let e of p)try{let o=g.get(e)??[],a=!1;for(let e of o){for(let o of e.catalog_ids??[]){let e=w.get(o);if(null!=e&&await r(e.filter_criteria,t.postState,t.p2pStore)){a=!0;break}}if(a)break}if(!a){I++;continue}let l={docID:t.entry.docID,modelID:t.entry.modelID,ownerDID:t.postState.owner,sourceGroupID:t.sourceGroupID,candidateGroupID:e,postState:t.postState};if(!await i(l)){I++;continue}if(!await t.p2pStore.recordBroadcast({docID:t.entry.docID,version:t.entry.version,mlsGroupID:e}))continue;let n={type:"mutation:apply",entries:[t.entry],senderPeerDID:t.selfDID};await t.scheduleBroadcast(e,n),D++}catch(r){t.logger?.warn("forwarder: candidate evaluation failed",{docID:t.entry.docID,version:t.entry.version,candidateGroupID:e,sourceGroupID:t.sourceGroupID,error:String(r)})}return{forwarded:D,skipped:I}}
|
package/lib/sync/handlers.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { type VerifyTokenHook } from '@enkaku/capability';
|
|
1
2
|
import type { KubunDB } from '@kubun/db';
|
|
3
|
+
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
2
4
|
import type { Logger } from '@kubun/logger';
|
|
3
5
|
import type { SyncDirection } from '../protocol.js';
|
|
6
|
+
import type { StoreUnreadableMode } from '../types.js';
|
|
4
7
|
import type { PeerRegistry } from './peer-registry.js';
|
|
5
8
|
/**
|
|
6
9
|
* Check if delegation tokens grant the viewer read access to a user's documents.
|
|
@@ -8,13 +11,29 @@ import type { PeerRegistry } from './peer-registry.js';
|
|
|
8
11
|
* @param viewerDID - The DID of the requesting viewer
|
|
9
12
|
* @param ownerDID - The DID of the document owner
|
|
10
13
|
* @param delegationTokens - Array of delegation JWT tokens
|
|
14
|
+
* @param revocationChecker - Optional per-leaf-capability verification hook.
|
|
15
|
+
* When set, capabilities whose `jti` has been revoked cause the check to
|
|
16
|
+
* fail closed.
|
|
11
17
|
* @returns true if any token grants read access
|
|
12
18
|
*/
|
|
13
|
-
export declare function checkSyncDelegation(viewerDID: string, ownerDID: string, delegationTokens: Array<string
|
|
19
|
+
export declare function checkSyncDelegation(viewerDID: string, ownerDID: string, delegationTokens: Array<string>, revocationChecker?: VerifyTokenHook): Promise<boolean>;
|
|
14
20
|
export type CreateSyncHandlersParams = {
|
|
15
21
|
db: KubunDB;
|
|
22
|
+
graph: GraphInternals;
|
|
16
23
|
logger: Logger;
|
|
17
24
|
peerRegistry: PeerRegistry;
|
|
25
|
+
/**
|
|
26
|
+
* Local peer's DID — required when `storeUnreadable === 'drop'` so the
|
|
27
|
+
* receive-time access gate can use it as viewer.
|
|
28
|
+
*/
|
|
29
|
+
selfDID?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Receive-side storage mode forwarded to `applySyncMutations`.
|
|
32
|
+
* Defaults to `'persist'` when omitted.
|
|
33
|
+
*/
|
|
34
|
+
storeUnreadable?: StoreUnreadableMode;
|
|
35
|
+
/** Server default access level — required when `storeUnreadable === 'drop'`. */
|
|
36
|
+
defaultAccessLevel?: DefaultAccessLevel;
|
|
18
37
|
};
|
|
19
38
|
/**
|
|
20
39
|
* Resolve the effective sync direction given what the initiator requested
|
package/lib/sync/handlers.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{checkCapability as e}from"@enkaku/capability";import{getGraphStore as
|
|
1
|
+
import{checkCapability as e,createRevocationChecker as t}from"@enkaku/capability";import{getGraphStore as n}from"@kubun/store-graph";import{createP2PRevocationBackend as r,getP2PStore as o}from"@kubun/store-p2p";import{resolveCatalogSyncScopes as l}from"./catalog-scope.js";import{createMerkleSyncChannelHandler as a}from"./merkle-channel.js";export async function checkSyncDelegation(t,n,r,o){if(!r||0===r.length)return!1;let l=["*","urn:kubun:user:*",`urn:kubun:user:${n}`],a="document/read";for(let s of l)try{return await e({act:a,res:s},{iss:t,sub:n,cap:r},{verifyToken:o}),!0}catch{}for(let s of r)for(let r of l)try{return await e({act:a,res:r},{iss:t,sub:n,cap:s},{verifyToken:o}),!0}catch{}return!1}export function negotiateDirection(e,t){return"both"===t?e:"both"===e?t:e===t?e:t}export function createSyncHandlers(e){let{db:s,graph:i,logger:c,peerRegistry:u}=e,f=e.storeUnreadable??"persist",d=null;async function g(){return null==d&&(d=t(r(await o(s)))),d}return{"sync/negotiate":async e=>{let t,{scopes:r,delegationTokens:o,catalogIDs:a,knownModelIDs:i,direction:f}=e.param,d=e.message.payload,p=d.sub||d.iss;c.debug("sync/negotiate requested",{scopes:r,catalogIDs:a,viewerDID:p});let D=[],m=await n(s);if(null!=a&&a.length>0){let e=await l(s,a,i);if(e.modelIDs.length>0)if(null!=e.owners)for(let t of e.modelIDs)for(let n of e.owners){let e=p===n,r=!e&&await checkSyncDelegation(p,n,o,await g());(e||r)&&D.push({modelID:t,ownerDID:n})}else for(let t of e.modelIDs)for(let e of(await m.getDistinctOwnersForModel(t))){let n=p===e,r=!n&&await checkSyncDelegation(p,e,o,await g());(n||r)&&D.push({modelID:t,ownerDID:e})}e.missingClusterIDs.length>0&&(t=await m.getClusters(e.missingClusterIDs))}if(null!=r)for(let e of r){let t=p===e.ownerDID,n=!t&&await checkSyncDelegation(p,e.ownerDID,o,await g());t||n?D.push(e):c.debug("sync/negotiate: scope rejected",{scope:e,viewerDID:p})}let w=new Set,h=D.filter(e=>{let t=`${e.modelID}:${e.ownerDID}`;return!w.has(t)&&(w.add(t),!0)});c.info("sync/negotiate completed",{requested:(r?.length??0)+(a?.length??0),accepted:h.length,missingClusters:null!=t?Object.keys(t).length:0});let y=f??"pull",b=await u.getPeer({peerDID:p,stores:s}),k=b?.allowedDirection??"both",I=negotiateDirection(y,k);return I!==y&&c.info("sync/negotiate: direction downgraded",{requested:y,allowed:k,agreed:I}),{direction:I,acceptedScopes:h,excludedDocumentIDs:[],...null!=t&&{missingClusters:t}}},"sync/merkle-sync":a({db:s,graph:i,logger:c,selfDID:e.selfDID,storeUnreadable:f,defaultAccessLevel:e.defaultAccessLevel})}}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
|
|
2
|
+
import { type ProcedureHandlers } from '@enkaku/server';
|
|
3
|
+
import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
|
|
4
|
+
import type { HubConnection } from '../hub/hub-connection.js';
|
|
5
|
+
import type { SyncProtocol } from '../protocol.js';
|
|
6
|
+
export type HubTunnelSyncListenerParams = {
|
|
7
|
+
hubConnection: HubConnection;
|
|
8
|
+
registry: GroupHandleRegistry;
|
|
9
|
+
groupID: string;
|
|
10
|
+
localDID: string;
|
|
11
|
+
peerDID: string;
|
|
12
|
+
syncHandlers: ProcedureHandlers<SyncProtocol>;
|
|
13
|
+
getRandomID?: () => string;
|
|
14
|
+
idleTimeoutMs?: number;
|
|
15
|
+
reconnectTimeoutMs?: number;
|
|
16
|
+
inboxCapacity?: number;
|
|
17
|
+
onEvent?: ObservabilityEventListener;
|
|
18
|
+
};
|
|
19
|
+
export declare class HubTunnelSyncListener {
|
|
20
|
+
#private;
|
|
21
|
+
constructor(params: HubTunnelSyncListenerParams);
|
|
22
|
+
start(): void;
|
|
23
|
+
stop(): Promise<void>;
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createEncryptedHubTunnelTransport as t}from"@enkaku/hub-tunnel";import{Server as e}from"@enkaku/server";import{MLSEncryptor as s}from"../groups/mls-encryptor.js";export class HubTunnelSyncListener{#t;#e;#s;#n;#i;#r;#o;#c;#a;#h;#p;#u=!1;#l=!1;#d;#D;#y;constructor(t){this.#t=t.hubConnection,this.#e=t.registry,this.#s=t.groupID,this.#n=t.localDID,this.#i=t.peerDID,this.#r=t.syncHandlers,this.#o=t.getRandomID,this.#c=t.idleTimeoutMs,this.#a=t.reconnectTimeoutMs,this.#h=t.inboxCapacity,this.#p=t.onEvent}start(){this.#u||this.#l||(this.#u=!0,this.#y=new s({registry:this.#e,groupID:this.#s}),this.#D=this.#t.tunnelMessageStream({peerDID:this.#i}),this.#m())}async stop(){if(this.#l)return;this.#l=!0;let t=this.#d;if(this.#d=void 0,null!=t){try{await t.transport.dispose()}catch{}try{await t.server.dispose()}catch{}}let e=this.#D;if(this.#D=void 0,null!=e)try{e.return()}catch{}this.#y=void 0}#m(){let s;if(this.#l)return;let n=this.#y;if(null==n)throw Error("HubTunnelSyncListener: encryptor not initialized; call start() first");let i=t({hub:this.#I(),encryptor:n,groupID:this.#s,sessionID:{auto:!0},localDID:this.#n,peerDID:this.#i,idleTimeoutMs:this.#c,reconnectTimeoutMs:this.#a,inboxCapacity:this.#h,onEvent:this.#p,onSessionEnd:()=>{s?.dispose().catch(()=>{})}});s=i;let r=new e({getRandomID:this.#o,handlers:this.#r,transports:[i]}),o={transport:i,server:r};this.#d=o,i.events.on("disposed",()=>{this.#d===o&&(this.#d=void 0),r.dispose().catch(()=>{}),this.#m()})}#I(){let t=this.#t,e=this.#D;if(null==e)throw Error("HubTunnelSyncListener: stream not initialized; call start() first");return{send:async e=>t.send({recipients:e.recipients,payload:e.payload}),receive:t=>e,events:{subscribe:e=>t.events.on("lifecycle",t=>{let s=function(t){switch(t.type){case"connected":return{type:"connected"};case"disconnected":return{type:"disconnected"};case"reconnecting":return{type:"reconnecting"};case"error":return}}(t);void 0!==s&&e(s)})}}}}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
|
|
2
|
+
import type { ClientTransportOf } from '@enkaku/protocol';
|
|
3
|
+
import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
|
|
4
|
+
import type { HubConnection } from '../hub/hub-connection.js';
|
|
5
|
+
import type { SyncProtocol } from '../protocol.js';
|
|
6
|
+
import type { SyncTransportProvider } from './sync-client.js';
|
|
7
|
+
export type HubTunnelSyncProviderParams = {
|
|
8
|
+
hubConnection: HubConnection;
|
|
9
|
+
registry: GroupHandleRegistry;
|
|
10
|
+
groupID: string;
|
|
11
|
+
localDID: string;
|
|
12
|
+
peerDID: string;
|
|
13
|
+
idleTimeoutMs?: number;
|
|
14
|
+
reconnectTimeoutMs?: number;
|
|
15
|
+
inboxCapacity?: number;
|
|
16
|
+
onEvent?: ObservabilityEventListener;
|
|
17
|
+
};
|
|
18
|
+
export declare class HubTunnelSyncProvider implements SyncTransportProvider {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(params: HubTunnelSyncProviderParams);
|
|
21
|
+
/**
|
|
22
|
+
* Build a fresh client transport for one tunnel sync session.
|
|
23
|
+
*
|
|
24
|
+
* **Concurrent-call constraint:** the underlying `HubConnection` keys its
|
|
25
|
+
* tunnel inbox by `peerDID`, and only one inbox per peer can be registered
|
|
26
|
+
* at a time. Callers must therefore fully dispose the previous transport
|
|
27
|
+
* (via `client.dispose()`) before invoking `createSyncTransport` again for
|
|
28
|
+
* the same provider instance — otherwise the second call throws
|
|
29
|
+
* `Error('tunnelMessageStream: peerDID already registered')`.
|
|
30
|
+
*
|
|
31
|
+
* The default `SyncManager.merkleSyncWithPeer` flow already awaits
|
|
32
|
+
* `client.dispose()` in its `finally` block, so sequential sync calls are
|
|
33
|
+
* safe. Concurrent calls are not.
|
|
34
|
+
*/
|
|
35
|
+
createSyncTransport(signal?: AbortSignal): ClientTransportOf<SyncProtocol>;
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createEncryptedHubTunnelTransport as e}from"@enkaku/hub-tunnel";import{MLSEncryptor as t}from"../groups/mls-encryptor.js";export class HubTunnelSyncProvider{#e;#t;#n;#r;#o;#i;#s;#c;#u;constructor(e){this.#e=e.hubConnection,this.#t=e.registry,this.#n=e.groupID,this.#r=e.localDID,this.#o=e.peerDID,this.#i=e.idleTimeoutMs,this.#s=e.reconnectTimeoutMs,this.#c=e.inboxCapacity,this.#u=e.onEvent}createSyncTransport(n){let r=crypto.randomUUID(),o=new t({registry:this.#t,groupID:this.#n});return e({hub:this.#a(),encryptor:o,groupID:this.#n,sessionID:r,localDID:this.#r,peerDID:this.#o,signal:n,idleTimeoutMs:this.#i,reconnectTimeoutMs:this.#s,inboxCapacity:this.#c,onEvent:this.#u})}#a(){let e=this.#e,t=this.#o;return{send:async t=>e.send({recipients:t.recipients,payload:t.payload}),receive(n){let r=e.tunnelMessageStream({peerDID:t}),o=r[Symbol.asyncIterator](),i={next:()=>o.next(),return:()=>(r.return(),Promise.resolve({value:void 0,done:!0}))};return{[Symbol.asyncIterator]:()=>i,return:()=>{r.return()}}},events:{subscribe:t=>e.events.on("lifecycle",e=>{let n=function(e){switch(e.type){case"connected":return{type:"connected"};case"disconnected":return{type:"disconnected"};case"reconnecting":return{type:"reconnecting"};case"error":return}}(e);void 0!==n&&t(n)})}}}}
|
|
@@ -1,12 +1,53 @@
|
|
|
1
1
|
import type { KubunDB } from '@kubun/db';
|
|
2
|
+
import { type DefaultAccessLevel, type GraphInternals } from '@kubun/engine';
|
|
3
|
+
import type { StoreUnreadableMode } from '../types.js';
|
|
2
4
|
export type ApplySyncMutationsParams = {
|
|
3
5
|
db: KubunDB;
|
|
6
|
+
graph: GraphInternals;
|
|
4
7
|
mutationJWTs: Array<string>;
|
|
8
|
+
/**
|
|
9
|
+
* Local peer's DID — required when `storeUnreadable === 'drop'` to feed the
|
|
10
|
+
* receive-time access gate. Optional otherwise.
|
|
11
|
+
*/
|
|
12
|
+
selfDID?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Receiver storage mode for incoming sync mutations.
|
|
15
|
+
* - `'persist'` (default) — every mutation the engine accepts persists.
|
|
16
|
+
* - `'drop'` — apply a receive-time read-access check using `selfDID` as
|
|
17
|
+
* viewer; mutations the local peer cannot read are not persisted.
|
|
18
|
+
*/
|
|
19
|
+
storeUnreadable?: StoreUnreadableMode;
|
|
20
|
+
/** Server default access level — required when `storeUnreadable === 'drop'`. */
|
|
21
|
+
defaultAccessLevel?: DefaultAccessLevel;
|
|
5
22
|
};
|
|
6
23
|
export type ApplySyncMutationsResult = {
|
|
7
24
|
applied: number;
|
|
8
25
|
rejected: number;
|
|
9
26
|
pending: number;
|
|
10
27
|
skipped: number;
|
|
28
|
+
/** Count of mutations denied by the receive-time access gate. */
|
|
29
|
+
dropped: number;
|
|
11
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Apply mutation JWTs received from a peer via Merkle sync.
|
|
33
|
+
*
|
|
34
|
+
* Routes the successful-apply path through {@link GraphInternals.applyVerifiedMutation}
|
|
35
|
+
* with `origin: 'peer'` so GraphQL subscriptions (and other engine event
|
|
36
|
+
* consumers) fire for peer-received documents. The engine handles JWT
|
|
37
|
+
* verification, validator cache, applying the mutation, and inserting the
|
|
38
|
+
* `status: 'applied'` mutation log entry.
|
|
39
|
+
*
|
|
40
|
+
* Paths that bypass the engine (manual mutation-log insert):
|
|
41
|
+
* - **skipped**: mutation hash already seen — no-op.
|
|
42
|
+
* - **pending**: change mutation for a document that doesn't exist yet;
|
|
43
|
+
* parked in the log with `status: 'pending'` to be resolved when the
|
|
44
|
+
* set arrives.
|
|
45
|
+
* - **rejected**: JWT verify/validate fails, or engine throws during apply;
|
|
46
|
+
* recorded with `status: 'rejected'`.
|
|
47
|
+
*
|
|
48
|
+
* We still verify + validate the JWT up front to peek at `mutation.typ` for
|
|
49
|
+
* the pending-path routing decision. The engine re-verifies on the success
|
|
50
|
+
* path (defense in depth + avoids plumbing verified payloads through the
|
|
51
|
+
* public API).
|
|
52
|
+
*/
|
|
12
53
|
export declare function applySyncMutations(params: ApplySyncMutationsParams): Promise<ApplySyncMutationsResult>;
|
package/lib/sync/merkle-apply.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{asType as t,createValidator as
|
|
1
|
+
import{asType as t,createValidator as e}from"@enkaku/schema";import{verifyToken as a}from"@enkaku/token";import{computeMutationHash as i}from"@kubun/engine";import{DocumentID as o}from"@kubun/id";import{documentMutation as n}from"@kubun/protocol";import{getGraphStore as r}from"@kubun/store-graph";import{getP2PStore as u}from"@kubun/store-p2p";import{createReceiveAccessGate as s}from"./receive-access-gate.js";let l=e(n);export async function applySyncMutations(e){let n,{db:c,graph:d,mutationJWTs:p}=e,f=await r(c);if("drop"===(e.storeUnreadable??"persist")){if(null==e.selfDID)throw Error("applySyncMutations: 'storeUnreadable: drop' requires selfDID");let t=await u(c);n=s({selfDID:e.selfDID,db:{getUserModelAccessDefault:(t,e,a)=>f.getUserModelAccessDefault(t,e,a),isMemberOfAnyCircle:(e,a)=>t.isMemberOfAnyCircle(e,a),isMemberOfAnyGroup:(e,a)=>t.isMemberOfAnyGroup(e,a),getModelInterfaces:t=>f.getModelInterfaces(t)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}})}let m=0,h=0,y=0,w=0,g=0;for(let e of p){let r,u=i(e);if(await f.hasMutationHash(u)){w++;continue}try{let i=await a(e);r=t(l,i.payload)}catch{h++;continue}let s=r.sub,c=o.fromString(s).model.toString(),p=o.fromString(s);if(null==await f.getDocument(p)&&"change"===r.typ){await f.insertMutationLogEntry({mutation_hash:u,model_id:c,document_id:s,author_did:r.iss,hlc:r.hlc,mutation_jwt:e,status:"pending"}),y++;continue}try{if((await d.applyVerifiedMutation({token:e,origin:"peer",...null!=n?{accessGate:n}:{}})).dropped){g++;continue}if(m++,"set"===r.typ)for(let t of(await f.getPendingMutations(s)))try{if((await d.applyVerifiedMutation({token:t.mutation_jwt,origin:"peer",...null!=n?{accessGate:n}:{}})).dropped){await f.updateMutationStatus(t.mutation_hash,"rejected"),g++;continue}await f.updateMutationStatus(t.mutation_hash,"applied"),m++}catch{await f.updateMutationStatus(t.mutation_hash,"rejected"),h++}}catch{await f.insertMutationLogEntry({mutation_hash:u,model_id:c,document_id:s,author_did:r.iss,hlc:r.hlc,mutation_jwt:e,status:"rejected"}),h++}}return{applied:m,rejected:h,pending:y,skipped:w,dropped:g}}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { KubunDB } from '@kubun/db';
|
|
2
|
+
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
2
3
|
import type { Logger } from '@kubun/logger';
|
|
3
4
|
import type { SyncMerkleSyncParams, SyncMerkleSyncReceive, SyncMerkleSyncResult, SyncMerkleSyncSend } from '../protocol.js';
|
|
5
|
+
import type { StoreUnreadableMode } from '../types.js';
|
|
4
6
|
/**
|
|
5
7
|
* Minimal handler context shape for sync/merkle-sync channel.
|
|
6
8
|
* Matches the Enkaku ChannelHandlerContext at runtime.
|
|
@@ -12,7 +14,20 @@ type MerkleSyncChannelContext = {
|
|
|
12
14
|
};
|
|
13
15
|
export type CreateMerkleSyncChannelHandlerParams = {
|
|
14
16
|
db: KubunDB;
|
|
17
|
+
graph: GraphInternals;
|
|
15
18
|
logger: Logger;
|
|
19
|
+
/**
|
|
20
|
+
* Local peer's DID — required when `storeUnreadable === 'drop'` so the
|
|
21
|
+
* receive-time access gate can use it as viewer.
|
|
22
|
+
*/
|
|
23
|
+
selfDID?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Receive-side storage mode forwarded to `applySyncMutations`.
|
|
26
|
+
* Defaults to `'persist'` when omitted.
|
|
27
|
+
*/
|
|
28
|
+
storeUnreadable?: StoreUnreadableMode;
|
|
29
|
+
/** Server default access level — required when `storeUnreadable === 'drop'`. */
|
|
30
|
+
defaultAccessLevel?: DefaultAccessLevel;
|
|
16
31
|
};
|
|
17
32
|
export declare function createMerkleSyncChannelHandler(params: CreateMerkleSyncChannelHandlerParams): (ctx: MerkleSyncChannelContext) => Promise<SyncMerkleSyncResult>;
|
|
18
33
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{getGraphStore as e}from"@kubun/store-graph";import{applySyncMutations as t}from"./merkle-apply.js";import{buildMerkleTree as r,findDivergentBuckets as a,getTimeBuckets as
|
|
1
|
+
import{getGraphStore as e}from"@kubun/store-graph";import{applySyncMutations as t}from"./merkle-apply.js";import{buildMerkleTree as r,findDivergentBuckets as a,getTimeBuckets as l,SYNC_BATCH_SIZE as n}from"./merkle-tree.js";export function createMerkleSyncChannelHandler(i){let{db:s,graph:c,logger:o}=i,u=i.storeUnreadable??"persist";return async m=>{let{scopes:p,excludedDocumentIDs:y,tree:f,direction:w}=m.param,d=m.writable.getWriter();o.info("sync/merkle-sync channel started",{scopes:p,excludedDocumentIDs:y,direction:w});let k=0,h=0,g=0;try{let b=await e(s),v=await b.getDocumentIDsForScope(p,y),D=await b.getMutationLogForDocuments(v),W=r(D);await d.write({type:"tree",tree:W.buckets});let J={root:f.root??"",buckets:f},L=a(W,J);k=L.length;let S=async()=>{if("pull"===w||"both"===w){let e=new Set(L),t=D.filter(t=>{let{minute:r}=l(t.hlc);return e.has(r)});for(let e=0;e<t.length;e+=n){let r=t.slice(e,e+n);await d.write({type:"mutations",mutationJWTs:r.map(e=>e.mutation_jwt)}),h+=r.length}await d.write({type:"complete"})}else await d.write({type:"complete"})},T=async()=>{let e=[],t=m.readable.getReader();try{for(;;){let{done:r,value:a}=await t.read();if(r)break;if("mutations"===a.type&&null!=a.mutationJWTs)e.push(...a.mutationJWTs);else if("complete"===a.type)break}}finally{t.releaseLock()}return e},[,j]=await Promise.all([S(),T()]);if(j.length>0){let e=await t({db:s,graph:c,mutationJWTs:j,selfDID:i.selfDID,storeUnreadable:u,defaultAccessLevel:i.defaultAccessLevel});g=e.applied+e.pending,o.info("sync/merkle-sync: applied received mutations",{result:e})}}catch(e){return o.error("sync/merkle-sync channel error",{error:e}),{success:!1,divergentBuckets:0,mutationsSent:0,mutationsReceived:0}}finally{try{await d.close()}catch{}}return o.info("sync/merkle-sync channel completed",{divergentBuckets:k,mutationsSent:h,mutationsReceived:g}),{success:!0,divergentBuckets:k,mutationsSent:h,mutationsReceived:g}}}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type AccessControlDB, type AccessGate, type DefaultAccessLevel } from '@kubun/engine';
|
|
2
|
+
/**
|
|
3
|
+
* Inputs for {@link createReceiveAccessGate}. The gate composes
|
|
4
|
+
* `resolveAccessRule` + `checkAccess` (via `createAccessChecker`) at receive
|
|
5
|
+
* time using the local peer's DID as viewer. Builds an `AccessControlDB`
|
|
6
|
+
* adapter from the underlying p2p + graph stores.
|
|
7
|
+
*/
|
|
8
|
+
export type ReceiveAccessGateParams = {
|
|
9
|
+
/** Local peer's DID — used as the viewer in the access check. */
|
|
10
|
+
selfDID: string;
|
|
11
|
+
/**
|
|
12
|
+
* Access-control DB facade — typically built inline from
|
|
13
|
+
* `graphStore.getUserModelAccessDefault` + `p2pStore.isMemberOfAnyCircle` +
|
|
14
|
+
* `p2pStore.isMemberOfAnyGroup`.
|
|
15
|
+
*/
|
|
16
|
+
db: AccessControlDB;
|
|
17
|
+
/** Server default access level used when no rule is declared. */
|
|
18
|
+
defaultAccessLevel: DefaultAccessLevel;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Build an {@link AccessGate} that returns `true` iff the local peer can
|
|
22
|
+
* read the post-state document under the resolved access rule.
|
|
23
|
+
*
|
|
24
|
+
* Used by the receive paths (`processBroadcast` mutation:apply branch and
|
|
25
|
+
* `applySyncMutations`) when `storeUnreadable: 'drop'` is configured. The
|
|
26
|
+
* gate is intentionally cheap to construct — the heavy work (rule
|
|
27
|
+
* resolution + membership lookups) happens lazily when the gate is invoked
|
|
28
|
+
* inside the engine's apply pipeline.
|
|
29
|
+
*
|
|
30
|
+
* Drop-mode access checks happen at receive time without delegation
|
|
31
|
+
* token context (the broadcast doesn't carry tokens). A peer who would
|
|
32
|
+
* gain read access only via a delegation token will have the doc
|
|
33
|
+
* dropped under `storeUnreadable: 'drop'`. Use `'persist'` if delegation
|
|
34
|
+
* chains must be honored at receive time.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createReceiveAccessGate(params: ReceiveAccessGateParams): AccessGate;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createAccessChecker as e}from"@kubun/engine";export function createReceiveAccessGate(c){let t=e({viewerDID:c.selfDID,db:c.db,defaultAccessLevel:c.defaultAccessLevel});return e=>t(e,"read")}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type ResolvedAccessRule = {
|
|
2
|
+
level: 'only_owner' | 'anyone' | 'restricted';
|
|
3
|
+
/** rule.allowedGroups (MLS group IDs) */
|
|
4
|
+
allowedGroupIDs: Set<string>;
|
|
5
|
+
/** MLS group IDs that are parents of any rule.allowedCircles entry */
|
|
6
|
+
allowedCircleParentGroupIDs: Set<string>;
|
|
7
|
+
/** MLS group IDs where at least one rule.allowedDIDs entry is a member */
|
|
8
|
+
allowedDIDGroupIDs: Set<string>;
|
|
9
|
+
};
|
|
10
|
+
export type BroadcastScopeInput = {
|
|
11
|
+
resolvedRule: ResolvedAccessRule;
|
|
12
|
+
/** MLS group IDs of sender's own memberships that already passed catalog-match for this doc */
|
|
13
|
+
senderGroupIDs: Array<string>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Compute the set of MLS group IDs to broadcast a document to,
|
|
17
|
+
* given its resolved access rule and the sender's group memberships.
|
|
18
|
+
*
|
|
19
|
+
* Pure, synchronous. The caller is responsible for:
|
|
20
|
+
* - Pre-resolving the access rule into a `ResolvedAccessRule` (all rule-side
|
|
21
|
+
* lookups — circle parent groups and per-DID group memberships —
|
|
22
|
+
* collapsed to MLS group ID sets).
|
|
23
|
+
* - Pre-filtering `senderGroupIDs` by catalog-match against the document.
|
|
24
|
+
*
|
|
25
|
+
* Per spec §3 "Sync routing" (sender side): a sender's own MLS group is a
|
|
26
|
+
* valid broadcast scope iff that group is the encryption perimeter for at
|
|
27
|
+
* least one allowed principal — either the group itself is in allowedGroups,
|
|
28
|
+
* or an allowed circle's parent group matches, or an allowed DID is a member
|
|
29
|
+
* of that group. The receiver applies the per-recipient access check on
|
|
30
|
+
* decrypt; the sender's own circle membership is irrelevant for routing.
|
|
31
|
+
*/
|
|
32
|
+
export declare function computeBroadcastScopes(input: BroadcastScopeInput): Array<string>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function computeBroadcastScopes(e){let{resolvedRule:r,senderGroupIDs:o}=e;switch(r.level){case"only_owner":return[];case"anyone":return Array.from(new Set(o));case"restricted":{let e=new Set;for(let t of o)(r.allowedGroupIDs.has(t)||r.allowedCircleParentGroupIDs.has(t)||r.allowedDIDGroupIDs.has(t))&&e.add(t);return Array.from(e)}default:{let e=r.level;throw Error(`Unknown access level: ${String(e)}`)}}}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Runtime } from '@enkaku/runtime';
|
|
2
2
|
import { type Identity, type SigningIdentity } from '@enkaku/token';
|
|
3
3
|
import type { StoreProvider } from '@kubun/db';
|
|
4
|
+
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
4
5
|
import type { Logger } from '@kubun/logger';
|
|
5
6
|
import type { SyncDirection } from '../protocol.js';
|
|
7
|
+
import type { StoreUnreadableMode } from '../types.js';
|
|
6
8
|
import { type PeerConfig, type PeerConfigWithID, PeerRegistry } from './peer-registry.js';
|
|
7
9
|
import { type SyncScope, type SyncTransportProvider } from './sync-client.js';
|
|
8
10
|
export type SyncSessionInfo = {
|
|
@@ -25,10 +27,18 @@ export type SyncEvent = {
|
|
|
25
27
|
};
|
|
26
28
|
export type SyncManagerParams = {
|
|
27
29
|
deployClusters?: (clusters: Record<string, unknown>) => Promise<void>;
|
|
30
|
+
graph: GraphInternals;
|
|
28
31
|
runtime: Runtime;
|
|
29
32
|
identity: Identity;
|
|
30
33
|
logger: Logger;
|
|
31
34
|
serverResolver?: (serverID: string) => SyncTransportProvider | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Receive-side storage mode forwarded to `applySyncMutations`.
|
|
37
|
+
* Defaults to `'persist'` when omitted.
|
|
38
|
+
*/
|
|
39
|
+
storeUnreadable?: StoreUnreadableMode;
|
|
40
|
+
/** Server default access level — required when `storeUnreadable === 'drop'`. */
|
|
41
|
+
defaultAccessLevel?: DefaultAccessLevel;
|
|
32
42
|
};
|
|
33
43
|
export type MerkleSyncParams = {
|
|
34
44
|
peerDID: string;
|
package/lib/sync/sync-manager.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{EventEmitter as e}from"@enkaku/event";import{isSigningIdentity as t}from"@enkaku/token";import{getGraphStore as
|
|
1
|
+
import{EventEmitter as e}from"@enkaku/event";import{isSigningIdentity as t}from"@enkaku/token";import{getGraphStore as s}from"@kubun/store-graph";import{applySyncMutations as r}from"./merkle-apply.js";import{buildMerkleTree as i}from"./merkle-tree.js";import{PeerRegistry as n}from"./peer-registry.js";import{SyncClient as o}from"./sync-client.js";export class SyncManager{#e;#t;#s;#r;#i;#n=new e;#o=new Map;#a=new Map;#l;#c;#g;#p;constructor(e){this.#e=e.deployClusters,this.#t=e.graph,this.#s=e.runtime,this.#l=e.identity,this.#r=e.logger,this.#i=new n({runtime:e.runtime}),this.#c=e.serverResolver,this.#g=e.storeUnreadable??"persist",this.#p=e.defaultAccessLevel}get peerRegistry(){return this.#i}setIdentity(e){this.#l=e}setServerResolver(e){this.#c=e}async addPeer(e){await this.#i.addPeer(e),this.#r.info("Peer added",{peerDID:e.config.peerDID})}async removePeer(e){await this.#i.removePeer(e),this.#r.info("Peer removed",{peerDID:e.peerDID})}async updatePeerConfig(e){await this.#i.updatePeer(e),this.#r.info("Peer updated",{peerDID:e.peerDID})}async listPeers(e){return this.#i.listPeers(e)}async getPeer(e){return this.#i.getPeer(e)}async merkleSyncWithPeer(e){let n,{peerDID:a,scopes:l,delegationTokens:c=[],knownModelIDs:g,direction:p}=e;this.#r.info("Starting Merkle sync with peer",{peerDID:a,scopes:l});let m=await this.#i.getPeer({peerDID:a,stores:e.stores});if(!m)throw Error(`Peer ${a} not found`);let d=`merkle-sync-${a}-${Date.now()}`,h={peerID:a,startTime:Date.now(),documentsAttempted:0,documentsCompleted:0};this.#o.set(d,h),this.#m({type:"started",peerID:a,timestamp:Date.now()});try{let u=this.#l;if(!t(u))throw Error("Signing identity required for Merkle sync");let y=new o({runtime:this.#s,identity:u,logger:this.#r,serverResolver:this.#c});n=await y.connect(m.endpoint,a);let v=p??"pull";this.#r.info("Negotiating sync scopes",{scopes:l,direction:v});let{acceptedScopes:f,excludedDocumentIDs:D,missingClusters:w,direction:k}=await y.negotiate(n,l,c,g,v);if(0===f.length)return this.#r.info("No scopes accepted by peer"),this.#a.set(a,Date.now()),this.#m({type:"completed",peerID:a,timestamp:Date.now()}),this.#o.delete(d),{sessionID:d,divergentBuckets:0,messagesReceived:0,messagesSent:0,missingClusters:w};null!=w&&Object.keys(w).length>0&&(null!=this.#e?(this.#r.info("Deploying missing clusters from peer",{clusterCount:Object.keys(w).length}),await this.#e(w)):this.#r.warn("Missing clusters received but no deployClusters callback configured",{clusterCount:Object.keys(w).length})),this.#r.info("Building local Merkle tree",{acceptedScopes:f});let S=await s(e.stores),P=await S.getDocumentIDsForScope(f,D),R=await S.getMutationLogForDocuments(P),I=i(R),b=k??v;this.#r.info("Requesting Merkle sync from peer",{localTreeBuckets:Object.keys(I.buckets).length,direction:b});let C=await y.merkleSync(n,{scopes:f,excludedDocumentIDs:D,localTree:I,direction:b,localEntries:R});return C.mutationJWTs.length>0&&(this.#r.info("Applying sync mutations",{mutations:C.mutationJWTs.length}),h.documentsCompleted=(await r({db:e.stores,graph:this.#t,mutationJWTs:C.mutationJWTs,selfDID:this.#l.id,storeUnreadable:this.#g,defaultAccessLevel:this.#p})).applied),this.#a.set(a,Date.now()),this.#m({type:"completed",peerID:a,timestamp:Date.now()}),this.#r.info("Merkle sync completed",{divergentBuckets:C.divergentBuckets,mutationsReceived:C.mutationJWTs.length}),{sessionID:d,divergentBuckets:C.divergentBuckets,messagesReceived:C.mutationJWTs.length,messagesSent:C.mutationsSent,missingClusters:w}}catch(e){throw this.#r.error("Merkle sync failed",{peerDID:a,error:e}),this.#m({type:"error",peerID:a,timestamp:Date.now(),error:e instanceof Error?e.message:String(e)}),e}finally{if(null!=n)try{n.abort("SyncComplete"),await n.dispose()}catch{}this.#o.delete(d)}}getStatus(e){let{peerDID:t}=e,s=Array.from(this.#o.values()).filter(e=>!t||e.peerID===t).map(e=>({peerID:e.peerID,startTime:e.startTime,documentsAttempted:e.documentsAttempted,documentsCompleted:e.documentsCompleted})),r={};for(let[e,s]of this.#a.entries())t&&e!==t||(r[e]=s);return{activeSessions:s,lastSyncByPeer:r}}onSyncEvent(e){return this.#n.on("sync",e)}#m(e){this.#n.emit("sync",e).catch(e=>{this.#r.error("Error in sync event listener",{error:e})})}async dispose(){this.#o.clear(),this.#a.clear()}}
|