@kubun/plugin-p2p 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +3 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +105 -91
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +5 -1
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +61 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +11 -0
  28. package/lib/groups/group-health-monitor.js +139 -1
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +20 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +0 -6
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.js +198 -1
  73. package/lib/hub/wiring.d.ts +17 -48
  74. package/lib/hub/wiring.js +132 -1
  75. package/lib/index.d.ts +17 -16
  76. package/lib/index.js +392 -1
  77. package/lib/protocol.d.ts +1 -1
  78. package/lib/protocol.js +251 -1
  79. package/lib/schema.d.ts +2 -1
  80. package/lib/schema.js +439 -2
  81. package/lib/sync/authorize.d.ts +1 -1
  82. package/lib/sync/authorize.js +73 -1
  83. package/lib/sync/broadcast-queue.js +183 -1
  84. package/lib/sync/broadcast-sender.js +179 -1
  85. package/lib/sync/catalog-scope.js +68 -1
  86. package/lib/sync/errors.d.ts +3 -2
  87. package/lib/sync/errors.js +19 -1
  88. package/lib/sync/forwarder.js +277 -1
  89. package/lib/sync/handlers.js +160 -1
  90. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
  91. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  92. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  93. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  94. package/lib/sync/merkle-apply.d.ts +5 -0
  95. package/lib/sync/merkle-apply.js +194 -1
  96. package/lib/sync/merkle-channel.d.ts +2 -2
  97. package/lib/sync/merkle-channel.js +182 -1
  98. package/lib/sync/merkle-tree.js +120 -1
  99. package/lib/sync/peer-registry.d.ts +1 -1
  100. package/lib/sync/peer-registry.js +107 -1
  101. package/lib/sync/receive-access-gate.js +24 -1
  102. package/lib/sync/scope-resolver.js +41 -1
  103. package/lib/sync/sync-client.d.ts +3 -3
  104. package/lib/sync/sync-client.js +192 -1
  105. package/lib/sync/sync-manager.d.ts +2 -2
  106. package/lib/sync/sync-manager.js +272 -1
  107. package/lib/types.d.ts +11 -0
  108. package/lib/types.js +2 -1
  109. package/lib/util/mutex.js +10 -1
  110. package/lib/utils.js +21 -1
  111. package/package.json +47 -40
  112. package/lib/groups/broadcast-service.d.ts +0 -263
  113. package/lib/groups/broadcast-service.js +0 -1
  114. package/lib/groups/rejoin-codec.d.ts +0 -14
  115. package/lib/groups/rejoin-codec.js +0 -1
  116. package/lib/groups/wire-frame.d.ts +0 -34
  117. package/lib/groups/wire-frame.js +0 -1
  118. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  119. package/lib/hub/circle-catchup-requester.js +0 -1
  120. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  121. package/lib/hub/circle-catchup-responder.js +0 -1
  122. package/lib/hub/connection-pool.d.ts +0 -43
  123. package/lib/hub/connection-pool.js +0 -1
  124. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  125. package/lib/hub/epoch-stale-detector.js +0 -1
  126. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  127. package/lib/hub/forward-remote-broadcast.js +0 -1
  128. package/lib/hub/group-channel.d.ts +0 -89
  129. package/lib/hub/group-channel.js +0 -1
  130. package/lib/hub/hub-connection.d.ts +0 -96
  131. package/lib/hub/hub-connection.js +0 -1
  132. package/lib/hub/receive-handler.d.ts +0 -51
  133. package/lib/hub/receive-handler.js +0 -1
  134. package/lib/hub/rejoin-manager.d.ts +0 -78
  135. package/lib/hub/rejoin-manager.js +0 -1
  136. package/lib/hub/rejoin-responder.d.ts +0 -32
  137. package/lib/hub/rejoin-responder.js +0 -1
  138. package/lib/hub/relay-manager.d.ts +0 -142
  139. package/lib/hub/relay-manager.js +0 -1
  140. package/lib/hub/send-handler.d.ts +0 -40
  141. package/lib/hub/send-handler.js +0 -1
  142. package/lib/hub/tunnel-inbox.d.ts +0 -20
  143. package/lib/hub/tunnel-inbox.js +0 -1
  144. package/lib/hub/wait-for-gate.d.ts +0 -14
  145. package/lib/hub/wait-for-gate.js +0 -1
@@ -1,11 +1,15 @@
1
- import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
2
1
  import type { ClientTransportOf } from '@enkaku/protocol';
2
+ import { type HubLike, type ObservabilityEventListener } from '@kumiai/hub-tunnel';
3
3
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
4
- import type { HubConnection } from '../hub/hub-connection.js';
5
4
  import type { SyncProtocol } from '../protocol.js';
6
5
  import type { SyncTransportProvider } from './sync-client.js';
7
6
  export type HubTunnelSyncProviderParams = {
8
- hubConnection: HubConnection;
7
+ /**
8
+ * Shared multi-subscriber device hub (from `createHubLike`). The transport's
9
+ * `receive()` attaches its own sink to this device-wide drain and filters to
10
+ * `receiveTopicID`; lifecycle events ride `hub.events`.
11
+ */
12
+ hub: HubLike;
9
13
  registry: GroupHandleRegistry;
10
14
  groupID: string;
11
15
  localDID: string;
@@ -21,16 +25,12 @@ export declare class HubTunnelSyncProvider implements SyncTransportProvider {
21
25
  /**
22
26
  * Build a fresh client transport for one tunnel sync session.
23
27
  *
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.
28
+ * The tunnel rides group-scoped inbox topics derived from the group's current
29
+ * MLS epoch secret: outbound frames publish to the peer's inbox
30
+ * (`inboxTopic(secret, epoch, peerDID)`) and the transport subscribes to this
31
+ * device's own inbox (`inboxTopic(secret, epoch, localDID)`). Topics rotate
32
+ * with the epoch, so a transport built in epoch N stops matching once a commit
33
+ * advances the group — callers create a new transport per session.
34
34
  */
35
- createSyncTransport(signal?: AbortSignal): ClientTransportOf<SyncProtocol>;
35
+ createSyncTransport(signal?: AbortSignal): Promise<ClientTransportOf<SyncProtocol>>;
36
36
  }
@@ -1 +1,64 @@
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
+ import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
2
+ import { inboxTopic } from '@kumiai/rpc';
3
+ import { createGroupCrypto } from '../groups/group-crypto.js';
4
+ import { MLSEncryptor } from '../groups/mls-encryptor.js';
5
+ export class HubTunnelSyncProvider {
6
+ #hub;
7
+ #registry;
8
+ #groupID;
9
+ #localDID;
10
+ #peerDID;
11
+ #idleTimeoutMs;
12
+ #reconnectTimeoutMs;
13
+ #inboxCapacity;
14
+ #onEvent;
15
+ constructor(params){
16
+ this.#hub = params.hub;
17
+ this.#registry = params.registry;
18
+ this.#groupID = params.groupID;
19
+ this.#localDID = params.localDID;
20
+ this.#peerDID = params.peerDID;
21
+ this.#idleTimeoutMs = params.idleTimeoutMs;
22
+ this.#reconnectTimeoutMs = params.reconnectTimeoutMs;
23
+ this.#inboxCapacity = params.inboxCapacity;
24
+ this.#onEvent = params.onEvent;
25
+ }
26
+ /**
27
+ * Build a fresh client transport for one tunnel sync session.
28
+ *
29
+ * The tunnel rides group-scoped inbox topics derived from the group's current
30
+ * MLS epoch secret: outbound frames publish to the peer's inbox
31
+ * (`inboxTopic(secret, epoch, peerDID)`) and the transport subscribes to this
32
+ * device's own inbox (`inboxTopic(secret, epoch, localDID)`). Topics rotate
33
+ * with the epoch, so a transport built in epoch N stops matching once a commit
34
+ * advances the group — callers create a new transport per session.
35
+ */ async createSyncTransport(signal) {
36
+ const sessionID = generateSessionID();
37
+ const encryptor = new MLSEncryptor({
38
+ registry: this.#registry,
39
+ groupID: this.#groupID
40
+ });
41
+ const crypto1 = createGroupCrypto(this.#registry, this.#groupID);
42
+ const secret = await crypto1.exportSecret();
43
+ const epoch = crypto1.epoch();
44
+ const sendTopicID = inboxTopic(secret, epoch, this.#peerDID);
45
+ const receiveTopicID = inboxTopic(secret, epoch, this.#localDID);
46
+ return createEncryptedHubTunnelTransport({
47
+ hub: this.#hub,
48
+ encryptor,
49
+ groupID: this.#groupID,
50
+ sessionID,
51
+ localDID: this.#localDID,
52
+ sendTopicID,
53
+ receiveTopicID,
54
+ signal,
55
+ idleTimeoutMs: this.#idleTimeoutMs,
56
+ reconnectTimeoutMs: this.#reconnectTimeoutMs,
57
+ inboxCapacity: this.#inboxCapacity,
58
+ onEvent: this.#onEvent
59
+ });
60
+ }
61
+ }
62
+ function generateSessionID() {
63
+ return crypto.randomUUID();
64
+ }
@@ -27,6 +27,11 @@ export type ApplySyncMutationsResult = {
27
27
  skipped: number;
28
28
  /** Count of mutations denied by the receive-time access gate. */
29
29
  dropped: number;
30
+ /**
31
+ * Count of mutations whose apply failed transiently (store/db error). No log
32
+ * row is written for these, so the next merkle round re-fetches and retries.
33
+ */
34
+ deferred: number;
30
35
  };
31
36
  /**
32
37
  * Apply mutation JWTs received from a peer via Merkle sync.
@@ -1 +1,194 @@
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
+ import { verifyToken } from '@kokuin/token';
2
+ import { computeMutationHash } from '@kubun/engine';
3
+ import { DocumentID } from '@kubun/id';
4
+ import { isPermanentApplyError } from '@kubun/mutation';
5
+ import { documentMutation } from '@kubun/protocol';
6
+ import { getGraphStore } from '@kubun/store-graph';
7
+ import { asType, createValidator } from '@sozai/schema';
8
+ import { rethrowIfProgrammerError } from './forwarder.js';
9
+ import { createReceiveAccessGate } from './receive-access-gate.js';
10
+ const validateMutation = createValidator(documentMutation);
11
+ /**
12
+ * Apply mutation JWTs received from a peer via Merkle sync.
13
+ *
14
+ * Routes the successful-apply path through {@link GraphInternals.applyVerifiedMutation}
15
+ * with `origin: 'peer'` so GraphQL subscriptions (and other engine event
16
+ * consumers) fire for peer-received documents. The engine handles JWT
17
+ * verification, validator cache, applying the mutation, and inserting the
18
+ * `status: 'applied'` mutation log entry.
19
+ *
20
+ * Paths that bypass the engine (manual mutation-log insert):
21
+ * - **skipped**: mutation hash already seen — no-op.
22
+ * - **pending**: change mutation for a document that doesn't exist yet;
23
+ * parked in the log with `status: 'pending'` to be resolved when the
24
+ * set arrives.
25
+ * - **rejected**: JWT verify/validate fails, or engine throws during apply;
26
+ * recorded with `status: 'rejected'`.
27
+ *
28
+ * We still verify + validate the JWT up front to peek at `mutation.typ` for
29
+ * the pending-path routing decision. The engine re-verifies on the success
30
+ * path (defense in depth + avoids plumbing verified payloads through the
31
+ * public API).
32
+ */ export async function applySyncMutations(params) {
33
+ const { db, graph, mutationJWTs } = params;
34
+ const graphStore = await getGraphStore(db);
35
+ const storeUnreadable = params.storeUnreadable ?? 'persist';
36
+ // Build the receive-time access gate as a factory over the apply's
37
+ // transaction-scoped stores so its access reads run inside the apply
38
+ // transaction (a main-connection read there deadlocks on single-connection
39
+ // SQLite). When `'persist'` (default), the gate is omitted —
40
+ // `applyVerifiedMutation` takes its existing zero-overhead no-gate path.
41
+ let accessGate;
42
+ if (storeUnreadable === 'drop') {
43
+ if (params.selfDID == null) {
44
+ throw new Error("applySyncMutations: 'storeUnreadable: drop' requires selfDID");
45
+ }
46
+ const selfDID = params.selfDID;
47
+ const defaultAccessLevel = params.defaultAccessLevel ?? {
48
+ read: 'only_owner',
49
+ write: 'only_owner'
50
+ };
51
+ accessGate = ({ graphStore: txGraphStore, p2pStore: txP2PStore })=>{
52
+ if (txP2PStore == null) {
53
+ return undefined;
54
+ }
55
+ return createReceiveAccessGate({
56
+ selfDID,
57
+ db: {
58
+ getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>txGraphStore.getUserModelAccessDefault(ownerDID, modelID, permissionType),
59
+ isMemberOfAnyCircle: (viewerDID, circleIDs)=>txP2PStore.isMemberOfAnyCircle(viewerDID, circleIDs),
60
+ isMemberOfAnyGroup: (viewerDID, groupIDs)=>txP2PStore.isMemberOfAnyGroup(viewerDID, groupIDs),
61
+ getModelInterfaces: (modelID)=>txGraphStore.getModelInterfaces(modelID)
62
+ },
63
+ defaultAccessLevel
64
+ });
65
+ };
66
+ }
67
+ let applied = 0;
68
+ let rejected = 0;
69
+ let pending = 0;
70
+ let skipped = 0;
71
+ let dropped = 0;
72
+ let deferred = 0;
73
+ for (const jwt of mutationJWTs){
74
+ const mutationHash = computeMutationHash(jwt);
75
+ // Step 1: Dedup
76
+ if (await graphStore.hasMutationHash(mutationHash)) {
77
+ skipped++;
78
+ continue;
79
+ }
80
+ // Step 2: Verify JWT signature — needed to peek at mutation.typ for
81
+ // pending-path routing.
82
+ let mutation;
83
+ try {
84
+ const verified = await verifyToken(jwt);
85
+ mutation = asType(validateMutation, verified.payload);
86
+ } catch {
87
+ rejected++;
88
+ continue;
89
+ }
90
+ const documentID = mutation.sub;
91
+ const modelID = DocumentID.fromString(documentID).model.toString();
92
+ // Step 3: Check if document exists for change mutations
93
+ const docID = DocumentID.fromString(documentID);
94
+ const doc = await graphStore.getDocument(docID);
95
+ if (doc == null && mutation.typ === 'change') {
96
+ await graphStore.insertMutationLogEntry({
97
+ mutation_hash: mutationHash,
98
+ model_id: modelID,
99
+ document_id: documentID,
100
+ author_did: mutation.iss,
101
+ hlc: mutation.hlc,
102
+ mutation_jwt: jwt,
103
+ status: 'pending'
104
+ });
105
+ pending++;
106
+ continue;
107
+ }
108
+ // Step 4: Route through engine so GraphQL subscriptions fire
109
+ try {
110
+ const applyResult = await graph.applyVerifiedMutation({
111
+ token: jwt,
112
+ origin: 'peer',
113
+ ...accessGate != null ? {
114
+ accessGate
115
+ } : {}
116
+ });
117
+ if (applyResult.dropped) {
118
+ dropped++;
119
+ continue;
120
+ }
121
+ applied++;
122
+ // Step 5: Resolve pending mutations after set
123
+ if (mutation.typ === 'set') {
124
+ const pendingMutations = await graphStore.getPendingMutations(documentID);
125
+ for (const pendingEntry of pendingMutations){
126
+ try {
127
+ const pendingResult = await graph.applyVerifiedMutation({
128
+ token: pendingEntry.mutation_jwt,
129
+ origin: 'peer',
130
+ ...accessGate != null ? {
131
+ accessGate
132
+ } : {}
133
+ });
134
+ if (pendingResult.dropped) {
135
+ // Pending entry denied by gate. Mark the original pending row
136
+ // resolved as `rejected` so downstream consumers don't keep
137
+ // retrying it. The doc itself is on disk via the parent set
138
+ // (which already passed the gate). Counted as `dropped`,
139
+ // not `rejected`, to keep semantics aligned with the gate
140
+ // definition.
141
+ await graphStore.updateMutationStatus(pendingEntry.mutation_hash, 'rejected');
142
+ dropped++;
143
+ continue;
144
+ }
145
+ // Engine inserted a fresh `applied` log entry for the pending
146
+ // mutation. Mark the original `pending` entry resolved too so
147
+ // downstream consumers see consistent state.
148
+ await graphStore.updateMutationStatus(pendingEntry.mutation_hash, 'applied');
149
+ applied++;
150
+ } catch (err) {
151
+ rethrowIfProgrammerError(err);
152
+ // A permanent reject converges the tree (mark resolved). A transient
153
+ // failure leaves the `pending` row untouched: its hash already
154
+ // dedups in the tree, so it is retried not by the next merkle round
155
+ // but when a later set to this document re-walks `getPendingMutations`.
156
+ if (isPermanentApplyError(err)) {
157
+ await graphStore.updateMutationStatus(pendingEntry.mutation_hash, 'rejected');
158
+ rejected++;
159
+ } else {
160
+ deferred++;
161
+ }
162
+ }
163
+ }
164
+ }
165
+ } catch (err) {
166
+ rethrowIfProgrammerError(err);
167
+ // A permanent reject persists a `rejected` row so the tree converges and
168
+ // the sender stops resending. A transient failure writes no row and is
169
+ // counted as deferred, so the next merkle round re-fetches and retries.
170
+ if (isPermanentApplyError(err)) {
171
+ await graphStore.insertMutationLogEntry({
172
+ mutation_hash: mutationHash,
173
+ model_id: modelID,
174
+ document_id: documentID,
175
+ author_did: mutation.iss,
176
+ hlc: mutation.hlc,
177
+ mutation_jwt: jwt,
178
+ status: 'rejected'
179
+ });
180
+ rejected++;
181
+ } else {
182
+ deferred++;
183
+ }
184
+ }
185
+ }
186
+ return {
187
+ applied,
188
+ rejected,
189
+ pending,
190
+ skipped,
191
+ dropped,
192
+ deferred
193
+ };
194
+ }
@@ -1,5 +1,5 @@
1
- import type { VerifyTokenHook } from '@enkaku/capability';
2
- import type { SignedPayload } from '@enkaku/token';
1
+ import type { VerifyTokenHook } from '@kokuin/capability';
2
+ import type { SignedPayload } from '@kokuin/token';
3
3
  import type { KubunDB } from '@kubun/db';
4
4
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
5
5
  import type { Logger } from '@kubun/logger';
@@ -1 +1,182 @@
1
- import{getGraphStore as e}from"@kubun/store-graph";import{authorizeScope as t}from"./authorize.js";import{SyncAccessDeniedError as r}from"./errors.js";import{applySyncMutations as i}from"./merkle-apply.js";import{buildMerkleTree as n,findDivergentBuckets as a,getTimeBuckets as s,SYNC_BATCH_SIZE as o}from"./merkle-tree.js";import{negotiateDirection as l}from"./peer-registry.js";export function createMerkleSyncChannelHandler(c){let{db:d,graph:u,logger:m}=c,p=c.storeUnreadable??"persist";return async y=>{let{scopes:f,excludedDocumentIDs:w,tree:g,direction:h}=y.param,k=y.message?.payload;if(k?.iss==null)throw new r("unsigned","Sync access denied: missing verified signer");if(null!=k.sub&&k.sub!==k.iss)throw new r("forged-subject","Sync access denied: signed payload subject does not match issuer");let D=k.iss,b=null!=c.getRevocationChecker?await c.getRevocationChecker():void 0;for(let e of f)if(!await t({viewerDID:D,ownerDID:e.ownerDID,delegationTokens:y.param.delegationTokens??[],revocationChecker:b}))throw new r("scope-denied",`Sync access denied: caller not authorized for scope ${e.modelID}/${e.ownerDID}`);let v=h??"pull",j=await c.peerRegistry.getPeer({peerDID:D,stores:d});if(l(v,j?.allowedDirection??"both")!==v)throw new r("direction-denied",`Sync access denied: direction ${v} not permitted by peer policy`);let I=y.writable.getWriter();m.info("sync/merkle-sync channel started",{scopes:f,excludedDocumentIDs:w,direction:h});let S=0,T=0,R=0;try{let t=await e(d),r=await t.getDocumentIDsForScope(f,w),l=await t.getMutationLogForDocuments(r),k=n(l);await I.write({type:"tree",tree:k.buckets});let D={root:g.root??"",buckets:g},b=a(k,D);S=b.length;let v=async()=>{if("pull"===h||"both"===h){let e=new Set(b),t=l.filter(t=>{let{minute:r}=s(t.hlc);return e.has(r)});for(let e=0;e<t.length;e+=o){let r=t.slice(e,e+o);await I.write({type:"mutations",mutationJWTs:r.map(e=>e.mutation_jwt)}),T+=r.length}await I.write({type:"complete"})}else await I.write({type:"complete"})},j=async()=>{let e=[],t=y.readable.getReader();try{for(;;){let{done:r,value:i}=await t.read();if(r)break;if("mutations"===i.type&&null!=i.mutationJWTs)e.push(...i.mutationJWTs);else if("complete"===i.type)break}}finally{t.releaseLock()}return e},[,W]=await Promise.all([v(),j()]);if(W.length>0){let e=await i({db:d,graph:u,mutationJWTs:W,selfDID:c.selfDID,storeUnreadable:p,defaultAccessLevel:c.defaultAccessLevel});R=e.applied+e.pending,m.info("sync/merkle-sync: applied received mutations",{result:e})}}catch(e){return m.error("sync/merkle-sync channel error",{error:e}),{success:!1,divergentBuckets:0,mutationsSent:0,mutationsReceived:0}}finally{try{await I.close()}catch{}}return m.info("sync/merkle-sync channel completed",{divergentBuckets:S,mutationsSent:T,mutationsReceived:R}),{success:!0,divergentBuckets:S,mutationsSent:T,mutationsReceived:R}}}
1
+ import { getGraphStore } from '@kubun/store-graph';
2
+ import { authorizeScope } from './authorize.js';
3
+ import { SyncAccessDeniedError } from './errors.js';
4
+ import { applySyncMutations } from './merkle-apply.js';
5
+ import { buildMerkleTree, findDivergentBuckets, getTimeBuckets, SYNC_BATCH_SIZE } from './merkle-tree.js';
6
+ import { negotiateDirection } from './peer-registry.js';
7
+ export function createMerkleSyncChannelHandler(params) {
8
+ const { db, graph, logger } = params;
9
+ const storeUnreadable = params.storeUnreadable ?? 'persist';
10
+ return async (ctx)=>{
11
+ const { scopes, excludedDocumentIDs, tree: remoteTree, direction } = ctx.param;
12
+ // Authorize before any store read. The viewer is the cryptographically
13
+ // verified signer (`iss`). A signed payload always carries `iss`; no
14
+ // legitimate kubun client sets `sub`, so a `sub` that differs from `iss`
15
+ // is a forgery attempt and is denied here. Every requested scope must then
16
+ // be authorized (owner or valid delegation); the first unauthorized scope
17
+ // fails the whole request with no partial filtering and no store read.
18
+ // The throw propagates to the caller as a rejected channel call.
19
+ // The payload is always present under requireAuth (identity is set on every
20
+ // sync Server), but guard defensively: a caller that reaches this handler
21
+ // without a verified signer must be denied, never treated as a viewer.
22
+ const payload = ctx.message?.payload;
23
+ if (payload?.iss == null) {
24
+ throw new SyncAccessDeniedError('unsigned', 'Sync access denied: missing verified signer');
25
+ }
26
+ if (payload.sub != null && payload.sub !== payload.iss) {
27
+ throw new SyncAccessDeniedError('forged-subject', 'Sync access denied: signed payload subject does not match issuer');
28
+ }
29
+ const viewerDID = payload.iss;
30
+ const revocationChecker = params.getRevocationChecker != null ? await params.getRevocationChecker() : undefined;
31
+ for (const scope of scopes){
32
+ const authorized = await authorizeScope({
33
+ viewerDID,
34
+ ownerDID: scope.ownerDID,
35
+ delegationTokens: ctx.param.delegationTokens ?? [],
36
+ revocationChecker
37
+ });
38
+ if (!authorized) {
39
+ throw new SyncAccessDeniedError('scope-denied', `Sync access denied: caller not authorized for scope ${scope.modelID}/${scope.ownerDID}`);
40
+ }
41
+ }
42
+ // Re-resolve the requested direction against the peer's policy. A client can
43
+ // reach merkle-sync without negotiating, so this is the fail-closed backstop:
44
+ // a direction the peer policy does not allow is denied here, not downgraded.
45
+ // Unknown peers have no policy and default to 'both', so they pass through.
46
+ // Note the policy is keyed on the verified signing DID: it only applies when
47
+ // the signer matches a registered peer DID, so direction restriction is not
48
+ // enforced for callers whose signing identity differs from any peer record
49
+ // (the scope gate above, not this knob, is the access-control boundary).
50
+ const requestedDirection = direction ?? 'pull';
51
+ const peer = await params.peerRegistry.getPeer({
52
+ peerDID: viewerDID,
53
+ stores: db
54
+ });
55
+ const agreedDirection = negotiateDirection(requestedDirection, peer?.allowedDirection ?? 'both');
56
+ if (agreedDirection !== requestedDirection) {
57
+ throw new SyncAccessDeniedError('direction-denied', `Sync access denied: direction ${requestedDirection} not permitted by peer policy`);
58
+ }
59
+ const writer = ctx.writable.getWriter();
60
+ logger.info('sync/merkle-sync channel started', {
61
+ scopes,
62
+ excludedDocumentIDs,
63
+ direction
64
+ });
65
+ let divergentBucketCount = 0;
66
+ let mutationsSent = 0;
67
+ let mutationsReceived = 0;
68
+ try {
69
+ const graphStore = await getGraphStore(db);
70
+ // Step 1: Get all document IDs for the agreed scope
71
+ const documentIDs = await graphStore.getDocumentIDsForScope(scopes, excludedDocumentIDs);
72
+ // Step 2: Get all mutation log entries for these documents
73
+ const localEntries = await graphStore.getMutationLogForDocuments(documentIDs);
74
+ // Step 3: Build local merkle tree
75
+ const localTree = buildMerkleTree(localEntries);
76
+ // Step 4: Send local tree to initiator
77
+ await writer.write({
78
+ type: 'tree',
79
+ tree: localTree.buckets
80
+ });
81
+ // Step 5: Find divergent buckets
82
+ const remoteMerkle = {
83
+ root: remoteTree.root ?? '',
84
+ buckets: remoteTree
85
+ };
86
+ const divergentBuckets = findDivergentBuckets(localTree, remoteMerkle);
87
+ divergentBucketCount = divergentBuckets.length;
88
+ // Step 6: Concurrent send and receive
89
+ const sendTask = async ()=>{
90
+ if (direction === 'pull' || direction === 'both') {
91
+ // Filter local entries to those in divergent buckets
92
+ const divergentSet = new Set(divergentBuckets);
93
+ const entriesToSend = localEntries.filter((entry)=>{
94
+ const { minute } = getTimeBuckets(entry.hlc);
95
+ return divergentSet.has(minute);
96
+ });
97
+ // Send in batches
98
+ for(let i = 0; i < entriesToSend.length; i += SYNC_BATCH_SIZE){
99
+ const batch = entriesToSend.slice(i, i + SYNC_BATCH_SIZE);
100
+ await writer.write({
101
+ type: 'mutations',
102
+ mutationJWTs: batch.map((e)=>e.mutation_jwt)
103
+ });
104
+ mutationsSent += batch.length;
105
+ }
106
+ await writer.write({
107
+ type: 'complete'
108
+ });
109
+ } else {
110
+ // direction === 'push': no mutations to send, just signal complete
111
+ await writer.write({
112
+ type: 'complete'
113
+ });
114
+ }
115
+ };
116
+ const receiveTask = async ()=>{
117
+ const collectedJWTs = [];
118
+ const reader = ctx.readable.getReader();
119
+ try {
120
+ while(true){
121
+ const { done, value } = await reader.read();
122
+ if (done) break;
123
+ if (value.type === 'mutations' && value.mutationJWTs != null) {
124
+ collectedJWTs.push(...value.mutationJWTs);
125
+ } else if (value.type === 'complete') {
126
+ break;
127
+ }
128
+ }
129
+ } finally{
130
+ reader.releaseLock();
131
+ }
132
+ return collectedJWTs;
133
+ };
134
+ const [, receivedJWTs] = await Promise.all([
135
+ sendTask(),
136
+ receiveTask()
137
+ ]);
138
+ // Apply received mutations
139
+ if (receivedJWTs.length > 0) {
140
+ const result = await applySyncMutations({
141
+ db,
142
+ graph,
143
+ mutationJWTs: receivedJWTs,
144
+ selfDID: params.selfDID,
145
+ storeUnreadable,
146
+ defaultAccessLevel: params.defaultAccessLevel
147
+ });
148
+ mutationsReceived = result.applied + result.pending;
149
+ logger.info('sync/merkle-sync: applied received mutations', {
150
+ result
151
+ });
152
+ }
153
+ } catch (error) {
154
+ logger.error('sync/merkle-sync channel error', {
155
+ error
156
+ });
157
+ return {
158
+ success: false,
159
+ divergentBuckets: 0,
160
+ mutationsSent: 0,
161
+ mutationsReceived: 0
162
+ };
163
+ } finally{
164
+ try {
165
+ await writer.close();
166
+ } catch {
167
+ // Writer may already be closed by Enkaku's handler wrapper
168
+ }
169
+ }
170
+ logger.info('sync/merkle-sync channel completed', {
171
+ divergentBuckets: divergentBucketCount,
172
+ mutationsSent,
173
+ mutationsReceived
174
+ });
175
+ return {
176
+ success: true,
177
+ divergentBuckets: divergentBucketCount,
178
+ mutationsSent,
179
+ mutationsReceived
180
+ };
181
+ };
182
+ }
@@ -1 +1,120 @@
1
- import{blake3 as t}from"@noble/hashes/blake3.js";export const SYNC_BATCH_SIZE=1e3;let e=[4,7,10,16],o=new TextEncoder;export function getTimeBuckets(t){return{year:t.slice(0,4),month:t.slice(0,7),day:t.slice(0,10),minute:t.slice(0,16)}}function r(e){return Array.from(t(o.encode(e))).map(t=>t.toString(16).padStart(2,"0")).join("")}export function buildMerkleTree(t){if(0===t.length)return{root:"",buckets:{}};let e=new Map;for(let o of t){let{minute:t}=getTimeBuckets(o.hlc),r=e.get(t);null==r&&(r=[],e.set(t,r)),r.push(o)}let o={};for(let[t,n]of e){let e=[...n].sort((t,e)=>t.mutation_hash<e.mutation_hash?-1:+(t.mutation_hash>e.mutation_hash)).map(t=>t.mutation_hash).join("\n");o[t]=r(e)}for(let t of[10,7,4]){let e=10===t?16:7===t?10:7,n=new Map;for(let r of Object.keys(o)){if(r.length!==e)continue;let o=r.slice(0,t),s=n.get(o);null==s&&(s=[],n.set(o,s)),s.push(r)}for(let[t,e]of n){let n=e.sort().map(t=>`${t}\0${o[t]}`).join("\n");o[t]=r(n)}}let n=r(Object.keys(o).filter(t=>4===t.length).sort().map(t=>`${t}\0${o[t]}`).join("\n"));return o.root=n,{root:n,buckets:o}}export function findDivergentBuckets(t,o){if(t.root===o.root)return[];if(""===t.root)return Object.keys(o.buckets).filter(t=>16===t.length).sort();if(""===o.root)return Object.keys(t.buckets).filter(t=>16===t.length).sort();let r=[],n=new Set([...Object.keys(t.buckets),...Object.keys(o.buckets)]);return!function s(l,i){let u=e[i];for(let c of[...n].filter(t=>t.length===u&&(""===l||t.startsWith(l))).sort())t.buckets[c]!==o.buckets[c]&&(i===e.length-1?r.push(c):s(c,i+1))}("",0),r.sort()}
1
+ import { blake3 } from '@noble/hashes/blake3.js';
2
+ /** Maximum number of mutation JWTs to send in a single channel message. */ export const SYNC_BATCH_SIZE = 1000;
3
+ // Bucket key lengths: year=4, month=7, day=10, minute=16
4
+ const BUCKET_LEVELS = [
5
+ 4,
6
+ 7,
7
+ 10,
8
+ 16
9
+ ];
10
+ const MINUTE_KEY_LENGTH = 16;
11
+ const encoder = new TextEncoder();
12
+ export function getTimeBuckets(hlc) {
13
+ // HLC format: "2026-02-14T10:30:45.123Z:0001:nodeID"
14
+ return {
15
+ year: hlc.slice(0, 4),
16
+ month: hlc.slice(0, 7),
17
+ day: hlc.slice(0, 10),
18
+ minute: hlc.slice(0, 16)
19
+ };
20
+ }
21
+ function hashHex(input) {
22
+ const digest = blake3(encoder.encode(input));
23
+ return Array.from(digest).map((b)=>b.toString(16).padStart(2, '0')).join('');
24
+ }
25
+ export function buildMerkleTree(entries) {
26
+ if (entries.length === 0) {
27
+ return {
28
+ root: '',
29
+ buckets: {}
30
+ };
31
+ }
32
+ // Group entries by minute bucket
33
+ const minuteGroups = new Map();
34
+ for (const entry of entries){
35
+ const { minute } = getTimeBuckets(entry.hlc);
36
+ let group = minuteGroups.get(minute);
37
+ if (group == null) {
38
+ group = [];
39
+ minuteGroups.set(minute, group);
40
+ }
41
+ group.push(entry);
42
+ }
43
+ const buckets = {};
44
+ // Hash each minute bucket (leaf nodes)
45
+ for (const [minuteKey, bucketEntries] of minuteGroups){
46
+ const sorted = [
47
+ ...bucketEntries
48
+ ].sort((a, b)=>a.mutation_hash < b.mutation_hash ? -1 : a.mutation_hash > b.mutation_hash ? 1 : 0);
49
+ const content = sorted.map((e)=>e.mutation_hash).join('\n');
50
+ buckets[minuteKey] = hashHex(content);
51
+ }
52
+ // Build parent buckets bottom-up: day from minutes, month from days, year from months
53
+ const parentLevels = [
54
+ 10,
55
+ 7,
56
+ 4
57
+ ]// day, month, year (key lengths)
58
+ ;
59
+ for (const parentLength of parentLevels){
60
+ const childLength = parentLength === 10 ? 16 : parentLength === 7 ? 10 : 7;
61
+ const groups = new Map();
62
+ for (const key of Object.keys(buckets)){
63
+ if (key.length !== childLength) continue;
64
+ const parentKey = key.slice(0, parentLength);
65
+ let group = groups.get(parentKey);
66
+ if (group == null) {
67
+ group = [];
68
+ groups.set(parentKey, group);
69
+ }
70
+ group.push(key);
71
+ }
72
+ for (const [parentKey, childKeys] of groups){
73
+ const sorted = childKeys.sort();
74
+ const content = sorted.map((k)=>`${k}\0${buckets[k]}`).join('\n');
75
+ buckets[parentKey] = hashHex(content);
76
+ }
77
+ }
78
+ // Build root hash from year buckets
79
+ const yearKeys = Object.keys(buckets).filter((k)=>k.length === 4).sort();
80
+ const rootContent = yearKeys.map((k)=>`${k}\0${buckets[k]}`).join('\n');
81
+ const root = hashHex(rootContent);
82
+ buckets.root = root;
83
+ return {
84
+ root,
85
+ buckets
86
+ };
87
+ }
88
+ export function findDivergentBuckets(local, remote) {
89
+ if (local.root === remote.root) return [];
90
+ // If either tree is empty, all of the other's minute buckets diverge
91
+ if (local.root === '') {
92
+ return Object.keys(remote.buckets).filter((k)=>k.length === MINUTE_KEY_LENGTH).sort();
93
+ }
94
+ if (remote.root === '') {
95
+ return Object.keys(local.buckets).filter((k)=>k.length === MINUTE_KEY_LENGTH).sort();
96
+ }
97
+ const divergent = [];
98
+ const allKeys = new Set([
99
+ ...Object.keys(local.buckets),
100
+ ...Object.keys(remote.buckets)
101
+ ]);
102
+ function walk(prefix, levelIndex) {
103
+ const keyLength = BUCKET_LEVELS[levelIndex];
104
+ const keysAtLevel = [
105
+ ...allKeys
106
+ ].filter((k)=>k.length === keyLength && (prefix === '' || k.startsWith(prefix))).sort();
107
+ for (const key of keysAtLevel){
108
+ if (local.buckets[key] === remote.buckets[key]) continue;
109
+ if (levelIndex === BUCKET_LEVELS.length - 1) {
110
+ // Leaf level (minute) — this bucket diverges
111
+ divergent.push(key);
112
+ } else {
113
+ // Drill into next level
114
+ walk(key, levelIndex + 1);
115
+ }
116
+ }
117
+ }
118
+ walk('', 0);
119
+ return divergent.sort();
120
+ }
@@ -1,5 +1,5 @@
1
- import type { Runtime } from '@enkaku/runtime';
2
1
  import type { StoreProvider } from '@kubun/db';
2
+ import type { Runtime } from '@sozai/runtime';
3
3
  import type { SyncDirection } from '../protocol.js';
4
4
  /**
5
5
  * Resolve the effective sync direction given what the initiator requested