@kubun/plugin-p2p 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/lib/context/group.js +1 -1
  2. package/lib/context/hub.d.ts +4 -0
  3. package/lib/context/hub.js +1 -0
  4. package/lib/context/join.js +1 -1
  5. package/lib/context/sync.js +1 -1
  6. package/lib/context/types.d.ts +12 -0
  7. package/lib/groups/broadcast-service.d.ts +71 -17
  8. package/lib/groups/broadcast-service.js +1 -1
  9. package/lib/groups/broadcast.d.ts +112 -5
  10. package/lib/groups/broadcast.js +1 -1
  11. package/lib/groups/events.d.ts +11 -0
  12. package/lib/groups/group-handle-registry.d.ts +90 -0
  13. package/lib/groups/group-handle-registry.js +1 -0
  14. package/lib/groups/invite-payload.d.ts +5 -1
  15. package/lib/groups/join-utils.d.ts +2 -8
  16. package/lib/groups/join-utils.js +1 -1
  17. package/lib/groups/manager.d.ts +20 -14
  18. package/lib/groups/manager.js +1 -1
  19. package/lib/groups/mls-codec.d.ts +7 -0
  20. package/lib/groups/mls-codec.js +1 -0
  21. package/lib/groups/mls-encryptor.d.ts +25 -0
  22. package/lib/groups/mls-encryptor.js +1 -0
  23. package/lib/hub/connection-pool.d.ts +24 -0
  24. package/lib/hub/connection-pool.js +1 -1
  25. package/lib/hub/did-observing-transport.d.ts +64 -0
  26. package/lib/hub/did-observing-transport.js +1 -0
  27. package/lib/hub/errors.d.ts +28 -0
  28. package/lib/hub/errors.js +1 -0
  29. package/lib/hub/forward-remote-broadcast.d.ts +15 -0
  30. package/lib/hub/forward-remote-broadcast.js +1 -0
  31. package/lib/hub/group-channel.d.ts +29 -21
  32. package/lib/hub/group-channel.js +1 -1
  33. package/lib/hub/http-client.d.ts +17 -0
  34. package/lib/hub/http-client.js +1 -0
  35. package/lib/hub/hub-connection.d.ts +96 -0
  36. package/lib/hub/hub-connection.js +1 -0
  37. package/lib/hub/manager.d.ts +117 -0
  38. package/lib/hub/manager.js +1 -0
  39. package/lib/hub/receive-handler.d.ts +3 -6
  40. package/lib/hub/receive-handler.js +1 -1
  41. package/lib/hub/relay-manager.d.ts +85 -2
  42. package/lib/hub/relay-manager.js +1 -1
  43. package/lib/hub/send-handler.d.ts +12 -16
  44. package/lib/hub/send-handler.js +1 -1
  45. package/lib/hub/tunnel-inbox.d.ts +20 -0
  46. package/lib/hub/tunnel-inbox.js +1 -0
  47. package/lib/hub/wait-for-gate.d.ts +14 -0
  48. package/lib/hub/wait-for-gate.js +1 -0
  49. package/lib/hub/wiring.d.ts +73 -0
  50. package/lib/hub/wiring.js +1 -0
  51. package/lib/index.d.ts +102 -6
  52. package/lib/index.js +1 -1
  53. package/lib/schema.js +68 -20
  54. package/lib/sync/broadcast-queue.d.ts +59 -0
  55. package/lib/sync/broadcast-queue.js +1 -0
  56. package/lib/sync/broadcast-sender.d.ts +52 -0
  57. package/lib/sync/broadcast-sender.js +1 -0
  58. package/lib/sync/catalog-match.d.ts +13 -0
  59. package/lib/sync/catalog-match.js +1 -0
  60. package/lib/sync/forwarder.d.ts +96 -0
  61. package/lib/sync/forwarder.js +1 -0
  62. package/lib/sync/handlers.d.ts +15 -0
  63. package/lib/sync/handlers.js +1 -1
  64. package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
  65. package/lib/sync/hub-tunnel-sync-listener.js +1 -0
  66. package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
  67. package/lib/sync/hub-tunnel-sync-provider.js +1 -0
  68. package/lib/sync/merkle-apply.d.ts +41 -0
  69. package/lib/sync/merkle-apply.js +1 -1
  70. package/lib/sync/merkle-channel.d.ts +15 -0
  71. package/lib/sync/merkle-channel.js +1 -1
  72. package/lib/sync/receive-access-gate.d.ts +36 -0
  73. package/lib/sync/receive-access-gate.js +1 -0
  74. package/lib/sync/scope-resolver.d.ts +32 -0
  75. package/lib/sync/scope-resolver.js +1 -0
  76. package/lib/sync/sync-manager.d.ts +10 -0
  77. package/lib/sync/sync-manager.js +1 -1
  78. package/lib/types.d.ts +95 -26
  79. package/lib/util/mutex.d.ts +4 -0
  80. package/lib/util/mutex.js +1 -0
  81. package/package.json +40 -37
@@ -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>;
@@ -1 +1 @@
1
- import{asType as t,createValidator as a}from"@enkaku/schema";import{verifyToken as i}from"@enkaku/token";import{computeMutationHash as o}from"@kubun/engine";import{DocumentID as n}from"@kubun/id";import{applyMutation as u}from"@kubun/mutation";import{documentMutation as e}from"@kubun/protocol";import{getGraphStore as r}from"@kubun/store-graph";let m=a(e);export async function applySyncMutations(a){let{db:e,mutationJWTs:s}=a,d=await r(e),l=0,c=0,h=0,p=0,_={store:d,validators:{}};for(let a of s){let e,r=o(a);if(await d.hasMutationHash(r)){p++;continue}try{let o=await i(a);e=t(m,o.payload)}catch{c++;continue}let s=e.sub,f=n.fromString(s).model.toString(),w=n.fromString(s);if(null==await d.getDocument(w)&&"change"===e.typ){await d.insertMutationLogEntry({mutation_hash:r,model_id:f,document_id:s,author_did:e.iss,hlc:e.hlc,mutation_jwt:a,status:"pending"}),h++;continue}try{if(await u(_,e),await d.insertMutationLogEntry({mutation_hash:r,model_id:f,document_id:s,author_did:e.iss,hlc:e.hlc,mutation_jwt:a,status:"applied"}),l++,"set"===e.typ)for(let a of(await d.getPendingMutations(s)))try{let o=await i(a.mutation_jwt),n=t(m,o.payload);await u(_,n),await d.updateMutationStatus(a.mutation_hash,"applied"),l++}catch{await d.updateMutationStatus(a.mutation_hash,"rejected"),c++}}catch{await d.insertMutationLogEntry({mutation_hash:r,model_id:f,document_id:s,author_did:e.iss,hlc:e.hlc,mutation_jwt:a,status:"rejected"}),c++}}return{applied:l,rejected:c,pending:h,skipped:p}}
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:p,mutationJWTs:d}=e,m=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)=>m.getUserModelAccessDefault(t,e,a),isMemberOfAnyCircle:(e,a)=>t.isMemberOfAnyCircle(e,a),isMemberOfAnyGroup:(e,a)=>t.isMemberOfAnyGroup(e,a)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}})}let f=0,h=0,y=0,w=0,g=0;for(let e of d){let r,u=i(e);if(await m.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(),d=o.fromString(s);if(null==await m.getDocument(d)&&"change"===r.typ){await m.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 p.applyVerifiedMutation({token:e,origin:"peer",...null!=n?{accessGate:n}:{}})).dropped){g++;continue}if(f++,"set"===r.typ)for(let t of(await m.getPendingMutations(s)))try{if((await p.applyVerifiedMutation({token:t.mutation_jwt,origin:"peer",...null!=n?{accessGate:n}:{}})).dropped){await m.updateMutationStatus(t.mutation_hash,"rejected"),g++;continue}await m.updateMutationStatus(t.mutation_hash,"applied"),f++}catch{await m.updateMutationStatus(t.mutation_hash,"rejected"),h++}}catch{await m.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:f,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 n,SYNC_BATCH_SIZE as l}from"./merkle-tree.js";export function createMerkleSyncChannelHandler(i){let{db:o,logger:s}=i;return async i=>{let{scopes:c,excludedDocumentIDs:m,tree:u,direction:p}=i.param,y=i.writable.getWriter();s.info("sync/merkle-sync channel started",{scopes:c,excludedDocumentIDs:m,direction:p});let w=0,f=0,k=0;try{let h=await e(o),d=await h.getDocumentIDsForScope(c,m),g=await h.getMutationLogForDocuments(d),b=r(g);await y.write({type:"tree",tree:b.buckets});let v={root:u.root??"",buckets:u},W=a(b,v);w=W.length;let J=async()=>{if("pull"===p||"both"===p){let e=new Set(W),t=g.filter(t=>{let{minute:r}=n(t.hlc);return e.has(r)});for(let e=0;e<t.length;e+=l){let r=t.slice(e,e+l);await y.write({type:"mutations",mutationJWTs:r.map(e=>e.mutation_jwt)}),f+=r.length}await y.write({type:"complete"})}else await y.write({type:"complete"})},S=async()=>{let e=[],t=i.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},[,T]=await Promise.all([J(),S()]);if(T.length>0){let e=await t({db:o,mutationJWTs:T});k=e.applied+e.pending,s.info("sync/merkle-sync: applied received mutations",{result:e})}}catch(e){return s.error("sync/merkle-sync channel error",{error:e}),{success:!1,divergentBuckets:0,mutationsSent:0,mutationsReceived:0}}finally{try{await y.close()}catch{}}return s.info("sync/merkle-sync channel completed",{divergentBuckets:w,mutationsSent:f,mutationsReceived:k}),{success:!0,divergentBuckets:w,mutationsSent:f,mutationsReceived:k}}}
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;
@@ -1 +1 @@
1
- import{EventEmitter as e}from"@enkaku/event";import{isSigningIdentity as t}from"@enkaku/token";import{getGraphStore as r}from"@kubun/store-graph";import{applySyncMutations as s}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;#r;#s;#i=new e;#n=new Map;#o=new Map;#l;#a;constructor(e){this.#e=e.deployClusters,this.#t=e.runtime,this.#l=e.identity,this.#r=e.logger,this.#s=new n({runtime:e.runtime}),this.#a=e.serverResolver}get peerRegistry(){return this.#s}setIdentity(e){this.#l=e}setServerResolver(e){this.#a=e}async addPeer(e){await this.#s.addPeer(e),this.#r.info("Peer added",{peerDID:e.config.peerDID})}async removePeer(e){await this.#s.removePeer(e),this.#r.info("Peer removed",{peerDID:e.peerDID})}async updatePeerConfig(e){await this.#s.updatePeer(e),this.#r.info("Peer updated",{peerDID:e.peerDID})}async listPeers(e){return this.#s.listPeers(e)}async getPeer(e){return this.#s.getPeer(e)}async merkleSyncWithPeer(e){let n,{peerDID:l,scopes:a,delegationTokens:c=[],knownModelIDs:g,direction:m}=e;this.#r.info("Starting Merkle sync with peer",{peerDID:l,scopes:a});let p=await this.#s.getPeer({peerDID:l,stores:e.stores});if(!p)throw Error(`Peer ${l} not found`);let y=`merkle-sync-${l}-${Date.now()}`,u={peerID:l,startTime:Date.now(),documentsAttempted:0,documentsCompleted:0};this.#n.set(y,u),this.#c({type:"started",peerID:l,timestamp:Date.now()});try{let d=this.#l;if(!t(d))throw Error("Signing identity required for Merkle sync");let h=new o({runtime:this.#t,identity:d,logger:this.#r,serverResolver:this.#a});n=await h.connect(p.endpoint,l);let v=m??"pull";this.#r.info("Negotiating sync scopes",{scopes:a,direction:v});let{acceptedScopes:f,excludedDocumentIDs:D,missingClusters:w,direction:k}=await h.negotiate(n,a,c,g,v);if(0===f.length)return this.#r.info("No scopes accepted by peer"),this.#o.set(l,Date.now()),this.#c({type:"completed",peerID:l,timestamp:Date.now()}),this.#n.delete(y),{sessionID:y,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 r(e.stores),P=await S.getDocumentIDsForScope(f,D),R=await S.getMutationLogForDocuments(P),I=i(R),C=k??v;this.#r.info("Requesting Merkle sync from peer",{localTreeBuckets:Object.keys(I.buckets).length,direction:C});let B=await h.merkleSync(n,{scopes:f,excludedDocumentIDs:D,localTree:I,direction:C,localEntries:R});return B.mutationJWTs.length>0&&(this.#r.info("Applying sync mutations",{mutations:B.mutationJWTs.length}),u.documentsCompleted=(await s({db:e.stores,mutationJWTs:B.mutationJWTs})).applied),this.#o.set(l,Date.now()),this.#c({type:"completed",peerID:l,timestamp:Date.now()}),this.#r.info("Merkle sync completed",{divergentBuckets:B.divergentBuckets,mutationsReceived:B.mutationJWTs.length}),{sessionID:y,divergentBuckets:B.divergentBuckets,messagesReceived:B.mutationJWTs.length,messagesSent:B.mutationsSent,missingClusters:w}}catch(e){throw this.#r.error("Merkle sync failed",{peerDID:l,error:e}),this.#c({type:"error",peerID:l,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.#n.delete(y)}}getStatus(e){let{peerDID:t}=e,r=Array.from(this.#n.values()).filter(e=>!t||e.peerID===t).map(e=>({peerID:e.peerID,startTime:e.startTime,documentsAttempted:e.documentsAttempted,documentsCompleted:e.documentsCompleted})),s={};for(let[e,r]of this.#o.entries())t&&e!==t||(s[e]=r);return{activeSessions:r,lastSyncByPeer:s}}onSyncEvent(e){return this.#i.on("sync",e)}#c(e){this.#i.emit("sync",e).catch(e=>{this.#r.error("Error in sync event listener",{error:e})})}async dispose(){this.#n.clear(),this.#o.clear()}}
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()}}
package/lib/types.d.ts CHANGED
@@ -3,6 +3,15 @@ import type { SyncDirection, SyncProtocol } from './protocol.js';
3
3
  import type { PeerConfig, PeerConfigWithID } from './sync/peer-registry.js';
4
4
  import type { SyncScope } from './sync/sync-client.js';
5
5
  import type { SyncEvent, SyncStatus } from './sync/sync-manager.js';
6
+ /**
7
+ * Receiver storage mode for incoming peer mutations.
8
+ * - `'persist'` — store every doc that passes catalog/group checks. Access
9
+ * enforced at query time; unreadable docs remain in DB but filtered.
10
+ * - `'drop'` — apply read-access check at receive time using the local
11
+ * peer's own DID as viewer. Mutations the local peer cannot read are
12
+ * not persisted.
13
+ */
14
+ export type StoreUnreadableMode = 'persist' | 'drop';
6
15
  export type GroupData = {
7
16
  id: string;
8
17
  name: string;
@@ -37,6 +46,14 @@ export type CatalogData = {
37
46
  filterCriteria: string;
38
47
  createdAt: string;
39
48
  };
49
+ export type HubData = {
50
+ id: string;
51
+ label: string | null;
52
+ url: string;
53
+ serverDID: string | null;
54
+ createdAt: string;
55
+ updatedAt: string | null;
56
+ };
40
57
  export type InviteToGroupData = {
41
58
  invitePayload: string;
42
59
  groupID: string;
@@ -73,56 +90,103 @@ export type UpdateCircleInput = {
73
90
  catalogIDs?: Array<string>;
74
91
  };
75
92
  export type GroupRequestContext = {
76
- p2pCreateGroup: (input: {
93
+ create: (input: {
77
94
  name: string;
78
95
  description?: string;
96
+ hubs?: Array<{
97
+ url: string;
98
+ serverDID?: string | null;
99
+ }>;
79
100
  }) => Promise<GroupData>;
80
- p2pGetGroups: () => Promise<Array<GroupData>>;
81
- p2pGetGroup: (id: string) => Promise<GroupData | null>;
82
- p2pAddGroupMember: (groupID: string, memberDID: string, role: string) => Promise<GroupMemberData>;
83
- p2pGetGroupMembers: (groupID: string) => Promise<Array<GroupMemberData>>;
84
- p2pCreateCircle: (params: {
101
+ list: () => Promise<Array<GroupData>>;
102
+ get: (id: string) => Promise<GroupData | null>;
103
+ addMember: (groupID: string, memberDID: string, role: string) => Promise<GroupMemberData>;
104
+ listMembers: (groupID: string) => Promise<Array<GroupMemberData>>;
105
+ createCircle: (params: {
85
106
  groupID: string;
86
107
  name: string;
87
108
  description?: string;
88
109
  }) => Promise<CircleData>;
89
- p2pGetCircles: (groupID: string) => Promise<Array<CircleData>>;
90
- p2pAddCircleMember: (circleID: string, memberDID: string, role: string) => Promise<CircleMemberData>;
91
- p2pCreateCatalog: (params: {
110
+ listCircles: (groupID: string) => Promise<Array<CircleData>>;
111
+ addCircleMember: (circleID: string, memberDID: string, role: string) => Promise<CircleMemberData>;
112
+ createCatalog: (params: {
92
113
  name: string;
93
114
  description?: string;
94
115
  filterCriteria: string;
95
116
  }) => Promise<CatalogData>;
96
- p2pGetCatalogs: (ownerDID?: string) => Promise<Array<CatalogData>>;
97
- p2pInviteToGroup: (groupID: string, joinRequest: string) => Promise<InviteToGroupData>;
98
- p2pJoinGroup: (invitePayload: string, joinRequestPayload: string) => Promise<JoinGroupData>;
99
- p2pLeaveGroup: (groupID: string) => Promise<LeaveGroupData>;
100
- p2pRemoveGroupMember: (groupID: string, memberDID: string) => Promise<RemoveGroupMemberData>;
101
- p2pUpdateGroup: (input: UpdateGroupInput) => Promise<GroupData>;
102
- p2pUpdateCircle: (input: UpdateCircleInput) => Promise<CircleData>;
103
- p2pUpdateCatalog: (params: {
117
+ listCatalogs: (ownerDID?: string) => Promise<Array<CatalogData>>;
118
+ invite: (groupID: string, joinRequest: string) => Promise<InviteToGroupData>;
119
+ join: (invitePayload: string, joinRequestPayload: string) => Promise<JoinGroupData>;
120
+ leave: (groupID: string) => Promise<LeaveGroupData>;
121
+ removeMember: (groupID: string, memberDID: string) => Promise<RemoveGroupMemberData>;
122
+ update: (input: UpdateGroupInput) => Promise<GroupData>;
123
+ updateCircle: (input: UpdateCircleInput) => Promise<CircleData>;
124
+ updateCatalog: (params: {
104
125
  catalogID: string;
105
126
  name?: string;
106
127
  filterCriteria?: string;
107
128
  }) => Promise<CatalogData>;
108
- p2pDeleteCatalog: (catalogID: string) => Promise<DeleteCatalogData>;
109
- p2pDeleteCircle: (circleID: string) => Promise<DeleteCircleData>;
110
- p2pRemoveCircleMember: (circleID: string, memberDID: string) => Promise<RemoveCircleMemberData>;
129
+ deleteCatalog: (catalogID: string) => Promise<DeleteCatalogData>;
130
+ deleteCircle: (circleID: string) => Promise<DeleteCircleData>;
131
+ removeCircleMember: (circleID: string, memberDID: string) => Promise<RemoveCircleMemberData>;
111
132
  };
112
133
  export type SyncRequestContext = {
113
- syncAddPeer: (config: PeerConfig) => Promise<void>;
114
- syncRemovePeer: (peerDID: string) => Promise<void>;
115
- syncListPeers: () => Promise<Array<PeerConfigWithID>>;
116
- syncGetStatus: (peerDID?: string) => SyncStatus;
134
+ addPeer: (config: PeerConfig) => Promise<void>;
135
+ removePeer: (peerDID: string) => Promise<void>;
136
+ listPeers: () => Promise<Array<PeerConfigWithID>>;
137
+ getStatus: (peerDID?: string) => SyncStatus;
117
138
  };
118
139
  export type P2PJoinRequestContext = {
119
- p2pPrepareJoinRequest: () => Promise<{
140
+ prepareRequest: () => Promise<{
120
141
  joinRequest: string;
121
142
  }>;
122
- p2pCompleteJoin: (invitePayload: string) => Promise<{
143
+ complete: (invitePayload: string) => Promise<{
123
144
  group: GroupData | null;
124
145
  }>;
125
146
  };
147
+ export type CreateHubInput = {
148
+ url: string;
149
+ label?: string | null;
150
+ serverDID?: string | null;
151
+ };
152
+ export type UpdateHubInput = {
153
+ id: string;
154
+ url?: string;
155
+ label?: string | null;
156
+ serverDID?: string | null;
157
+ };
158
+ export type DeleteHubData = {
159
+ success: boolean;
160
+ boundGroupIDs: Array<string>;
161
+ };
162
+ export type HubRequestContext = {
163
+ list: () => Promise<Array<HubData>>;
164
+ get: (id: string) => Promise<HubData | null>;
165
+ create: (input: CreateHubInput) => Promise<HubData>;
166
+ update: (input: UpdateHubInput) => Promise<HubData>;
167
+ delete: (id: string, force?: boolean) => Promise<DeleteHubData>;
168
+ bind: (params: {
169
+ hubID: string;
170
+ groupID: string;
171
+ }) => Promise<boolean>;
172
+ unbind: (params: {
173
+ hubID: string;
174
+ groupID: string;
175
+ }) => Promise<boolean>;
176
+ listByGroup: (groupID: string) => Promise<Array<HubData>>;
177
+ listGroups: (hubID: string) => Promise<Array<GroupData>>;
178
+ };
179
+ export type P2PContext = {
180
+ group: GroupRequestContext;
181
+ sync: SyncRequestContext;
182
+ join: P2PJoinRequestContext;
183
+ hub: HubRequestContext;
184
+ };
185
+ declare module '@kubun/graphql' {
186
+ interface PluginContextMap {
187
+ p2p: P2PContext;
188
+ }
189
+ }
126
190
  export type SyncSessionResult = {
127
191
  sessionID: string;
128
192
  divergentBuckets: number;
@@ -133,6 +197,11 @@ export type SyncSessionResult = {
133
197
  export type SyncPluginAPI = {
134
198
  /** Resolves when the HTTP sync transport is registered (only present when http option is enabled). */
135
199
  syncReady?: Promise<void>;
200
+ /**
201
+ * Resolves when hub relay setup completes. `DISABLED_HUB.ready` is already
202
+ * resolved when the `hub` plugin option is absent.
203
+ */
204
+ hubReady: Promise<void>;
136
205
  addPeer(config: PeerConfig): Promise<void>;
137
206
  removePeer(peerDID: string): Promise<void>;
138
207
  listPeers(): Promise<Array<PeerConfigWithID>>;
@@ -0,0 +1,4 @@
1
+ export type Mutex = {
2
+ run<T>(fn: () => Promise<T>): Promise<T>;
3
+ };
4
+ export declare function createMutex(): Mutex;
@@ -0,0 +1 @@
1
+ export function createMutex(){let e=Promise.resolve();return{run(t){let r=e.then(()=>t());return e=r.catch(()=>{}),r}}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-p2p",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "license": "see LICENSE.md",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -14,47 +14,50 @@
14
14
  ],
15
15
  "sideEffects": false,
16
16
  "dependencies": {
17
- "@enkaku/async": "^0.14.1",
18
- "@enkaku/capability": "^0.14.0",
19
- "@enkaku/client": "^0.14.4",
20
- "@enkaku/codec": "^0.14.0",
21
- "@enkaku/event": "^0.14.1",
22
- "@enkaku/generator": "^0.14.0",
23
- "@enkaku/group": "^0.14.6",
24
- "@enkaku/http-client-transport": "^0.14.3",
25
- "@enkaku/http-server-transport": "^0.14.2",
26
- "@enkaku/hub-protocol": "^0.14.0",
27
- "@enkaku/protocol": "^0.14.0",
28
- "@enkaku/runtime": "^0.14.0",
29
- "@enkaku/schema": "^0.14.0",
30
- "@enkaku/server": "^0.14.4",
31
- "@enkaku/stream": "^0.14.1",
32
- "@enkaku/token": "0.14.1",
33
- "@enkaku/transport": "0.14.1",
17
+ "@enkaku/async": "^0.15.0",
18
+ "@enkaku/capability": "^0.15.0",
19
+ "@enkaku/client": "^0.15.1",
20
+ "@enkaku/codec": "^0.15.0",
21
+ "@enkaku/event": "^0.15.0",
22
+ "@enkaku/generator": "^0.15.0",
23
+ "@enkaku/group": "^0.15.0",
24
+ "@enkaku/http-client-transport": "^0.15.0",
25
+ "@enkaku/http-server-transport": "^0.15.0",
26
+ "@enkaku/hub-protocol": "^0.15.0",
27
+ "@enkaku/hub-tunnel": "^0.15.0",
28
+ "@enkaku/protocol": "^0.15.0",
29
+ "@enkaku/runtime": "^0.15.0",
30
+ "@enkaku/schema": "^0.15.0",
31
+ "@enkaku/server": "^0.15.0",
32
+ "@enkaku/stream": "^0.15.0",
33
+ "@enkaku/token": "^0.15.0",
34
+ "@enkaku/transport": "^0.15.0",
34
35
  "@noble/hashes": "^2.2.0",
35
36
  "graphql": "^16.13.2",
36
- "kysely": "^0.28.16",
37
- "@kubun/client": "^0.8.0",
38
- "@kubun/store-p2p": "^0.8.0",
39
- "@kubun/graphql": "^0.8.1",
40
- "@kubun/engine": "^0.8.2",
41
- "@kubun/id": "^0.8.0",
42
- "@kubun/hlc": "^0.8.0",
43
- "@kubun/mutation": "^0.8.1",
44
- "@kubun/db": "^0.8.1",
45
- "@kubun/store-graph": "^0.8.1",
46
- "@kubun/protocol": "^0.8.1",
47
- "@kubun/logger": "^0.8.0",
48
- "@kubun/db-adapter": "^0.8.1"
37
+ "kysely": "^0.28.17",
38
+ "@kubun/client": "^0.9.0",
39
+ "@kubun/db": "^0.9.0",
40
+ "@kubun/db-adapter": "^0.9.0",
41
+ "@kubun/engine": "^0.9.0",
42
+ "@kubun/store-p2p": "^0.9.0",
43
+ "@kubun/store-graph": "^0.9.0",
44
+ "@kubun/graphql": "^0.9.0",
45
+ "@kubun/hlc": "^0.9.0",
46
+ "@kubun/id": "^0.9.0",
47
+ "@kubun/logger": "^0.9.0",
48
+ "@kubun/mutation": "^0.9.0",
49
+ "@kubun/protocol": "^0.9.0"
49
50
  },
50
51
  "devDependencies": {
51
- "@enkaku/hub-server": "^0.14.1",
52
+ "@enkaku/hub-server": "^0.15.0",
52
53
  "get-port": "^7.2.0",
53
- "@kubun/db-better-sqlite": "^0.8.1",
54
- "@kubun/plugin-connector": "^0.8.1",
55
- "@kubun/plugin-rpc": "^0.8.1",
56
- "@kubun/plugin-http": "^0.8.1",
57
- "@kubun/test-utils": "^0.8.0"
54
+ "@kubun/db-node-sqlite": "^0.9.0",
55
+ "@kubun/db-better-sqlite": "^0.9.0",
56
+ "@kubun/hub": "^0.9.0",
57
+ "@kubun/plugin-connector": "^0.9.0",
58
+ "@kubun/plugin-http": "^0.9.0",
59
+ "@kubun/plugin-rpc": "^0.9.0",
60
+ "@kubun/test-utils": "^0.9.0"
58
61
  },
59
62
  "scripts": {
60
63
  "build:clean": "del lib",