@kubun/plugin-p2p 0.10.0 → 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 (136) 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 +12 -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 +122 -38
  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 +7 -2
  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 +69 -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 +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  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 +29 -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 +4 -8
  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.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,182 @@
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}}}
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,6 +1,13 @@
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
+ /**
5
+ * Resolve the effective sync direction given what the initiator requested
6
+ * and what the responder's per-peer policy allows.
7
+ * If allowed is 'both' (or unset), the request passes through.
8
+ * Otherwise the responder's policy wins.
9
+ */
10
+ export declare function negotiateDirection(requested: SyncDirection, allowed: SyncDirection): SyncDirection;
4
11
  export type PeerConfig = {
5
12
  peerDID: string;
6
13
  endpoint: string;
@@ -1 +1,107 @@
1
- import{getP2PStore as e}from"@kubun/store-p2p";function t(e){var t,r;let i="string"==typeof(t=e.config)?JSON.parse(t):t??{};return{id:e.id,peerDID:e.peer_did,endpoint:e.endpoint,mode:e.mode,allowedUsers:"string"==typeof(r=e.allowed_users)?JSON.parse(r):r,priority:e.priority,trustLevel:e.trust_level,allowedDirection:i.allowedDirection,createdAt:e.created_at,updatedAt:e.updated_at}}export class PeerRegistry{#e;constructor(e){this.#e=e.runtime}async addPeer(t){let r=await e(t.stores);await r.addSyncPeer({id:this.#e.getRandomID(),peer_did:t.config.peerDID,endpoint:t.config.endpoint,mode:t.config.mode,allowed_users:t.config.allowedUsers,priority:t.config.priority,trust_level:t.config.trustLevel,config:{allowedDirection:t.config.allowedDirection},created_at:Date.now(),updated_at:Date.now()})}async getPeer(r){let i=await e(r.stores),o=await i.getSyncPeer(r.peerDID);if(o)return t(o)}async listPeers(r){let i=await e(r.stores);return(await i.listSyncPeers()).map(e=>t(e))}async updatePeer(t){let r=await this.getPeer({peerDID:t.peerDID,stores:t.stores});if(!r)throw Error(`Peer ${t.peerDID} not found`);let i={...r,...t.updates},o=await e(t.stores);await o.updateSyncPeer(t.peerDID,{endpoint:i.endpoint,mode:i.mode,allowed_users:i.allowedUsers,priority:i.priority,trust_level:i.trustLevel,config:{allowedDirection:i.allowedDirection}})}async removePeer(t){let r=await e(t.stores);await r.removeSyncPeer(t.peerDID)}async isPeerAllowed(e){return void 0!==await this.getPeer({peerDID:e.peerDID,stores:e.stores})}}
1
+ import { getP2PStore } from '@kubun/store-p2p';
2
+ /**
3
+ * Resolve the effective sync direction given what the initiator requested
4
+ * and what the responder's per-peer policy allows.
5
+ * If allowed is 'both' (or unset), the request passes through.
6
+ * Otherwise the responder's policy wins.
7
+ */ export function negotiateDirection(requested, allowed) {
8
+ if (allowed === 'both') return requested;
9
+ if (requested === 'both') return allowed;
10
+ if (requested === allowed) return requested;
11
+ // Incompatible — responder policy wins
12
+ return allowed;
13
+ }
14
+ function parseAllowedUsers(value) {
15
+ if (typeof value === 'string') {
16
+ return JSON.parse(value);
17
+ }
18
+ return value;
19
+ }
20
+ function parseConfig(value) {
21
+ if (typeof value === 'string') {
22
+ return JSON.parse(value);
23
+ }
24
+ return value ?? {};
25
+ }
26
+ function rowToPeerConfig(row) {
27
+ const config = parseConfig(row.config);
28
+ return {
29
+ id: row.id,
30
+ peerDID: row.peer_did,
31
+ endpoint: row.endpoint,
32
+ mode: row.mode,
33
+ allowedUsers: parseAllowedUsers(row.allowed_users),
34
+ priority: row.priority,
35
+ trustLevel: row.trust_level,
36
+ allowedDirection: config.allowedDirection,
37
+ createdAt: row.created_at,
38
+ updatedAt: row.updated_at
39
+ };
40
+ }
41
+ export class PeerRegistry {
42
+ #runtime;
43
+ constructor(params){
44
+ this.#runtime = params.runtime;
45
+ }
46
+ async addPeer(params) {
47
+ const store = await getP2PStore(params.stores);
48
+ await store.addSyncPeer({
49
+ id: this.#runtime.getRandomID(),
50
+ peer_did: params.config.peerDID,
51
+ endpoint: params.config.endpoint,
52
+ mode: params.config.mode,
53
+ allowed_users: params.config.allowedUsers,
54
+ priority: params.config.priority,
55
+ trust_level: params.config.trustLevel,
56
+ config: {
57
+ allowedDirection: params.config.allowedDirection
58
+ },
59
+ created_at: Date.now(),
60
+ updated_at: Date.now()
61
+ });
62
+ }
63
+ async getPeer(params) {
64
+ const store = await getP2PStore(params.stores);
65
+ const row = await store.getSyncPeer(params.peerDID);
66
+ if (!row) return undefined;
67
+ return rowToPeerConfig(row);
68
+ }
69
+ async listPeers(params) {
70
+ const store = await getP2PStore(params.stores);
71
+ const rows = await store.listSyncPeers();
72
+ return rows.map((row)=>rowToPeerConfig(row));
73
+ }
74
+ async updatePeer(params) {
75
+ const current = await this.getPeer({
76
+ peerDID: params.peerDID,
77
+ stores: params.stores
78
+ });
79
+ if (!current) throw new Error(`Peer ${params.peerDID} not found`);
80
+ const updated = {
81
+ ...current,
82
+ ...params.updates
83
+ };
84
+ const store = await getP2PStore(params.stores);
85
+ await store.updateSyncPeer(params.peerDID, {
86
+ endpoint: updated.endpoint,
87
+ mode: updated.mode,
88
+ allowed_users: updated.allowedUsers,
89
+ priority: updated.priority,
90
+ trust_level: updated.trustLevel,
91
+ config: {
92
+ allowedDirection: updated.allowedDirection
93
+ }
94
+ });
95
+ }
96
+ async removePeer(params) {
97
+ const store = await getP2PStore(params.stores);
98
+ await store.removeSyncPeer(params.peerDID);
99
+ }
100
+ async isPeerAllowed(params) {
101
+ const peer = await this.getPeer({
102
+ peerDID: params.peerDID,
103
+ stores: params.stores
104
+ });
105
+ return peer !== undefined;
106
+ }
107
+ }
@@ -1 +1,24 @@
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")}
1
+ import { createAccessChecker } from '@kubun/engine';
2
+ /**
3
+ * Build an {@link AccessGate} that returns `true` iff the local peer can
4
+ * read the post-state document under the resolved access rule.
5
+ *
6
+ * Used by the receive paths (`processBroadcast` mutation:apply branch and
7
+ * `applySyncMutations`) when `storeUnreadable: 'drop'` is configured. The
8
+ * gate is intentionally cheap to construct — the heavy work (rule
9
+ * resolution + membership lookups) happens lazily when the gate is invoked
10
+ * inside the engine's apply pipeline.
11
+ *
12
+ * Drop-mode access checks happen at receive time without delegation
13
+ * token context (the broadcast doesn't carry tokens). A peer who would
14
+ * gain read access only via a delegation token will have the doc
15
+ * dropped under `storeUnreadable: 'drop'`. Use `'persist'` if delegation
16
+ * chains must be honored at receive time.
17
+ */ export function createReceiveAccessGate(params) {
18
+ const checker = createAccessChecker({
19
+ viewerDID: params.selfDID,
20
+ db: params.db,
21
+ defaultAccessLevel: params.defaultAccessLevel
22
+ });
23
+ return (postState)=>checker(postState, 'read');
24
+ }
@@ -1 +1,41 @@
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
+ /**
2
+ * Compute the set of MLS group IDs to broadcast a document to,
3
+ * given its resolved access rule and the sender's group memberships.
4
+ *
5
+ * Pure, synchronous. The caller is responsible for:
6
+ * - Pre-resolving the access rule into a `ResolvedAccessRule` (all rule-side
7
+ * lookups — circle parent groups and per-DID group memberships —
8
+ * collapsed to MLS group ID sets).
9
+ * - Pre-filtering `senderGroupIDs` by catalog-match against the document.
10
+ *
11
+ * Per spec §3 "Sync routing" (sender side): a sender's own MLS group is a
12
+ * valid broadcast scope iff that group is the encryption perimeter for at
13
+ * least one allowed principal — either the group itself is in allowedGroups,
14
+ * or an allowed circle's parent group matches, or an allowed DID is a member
15
+ * of that group. The receiver applies the per-recipient access check on
16
+ * decrypt; the sender's own circle membership is irrelevant for routing.
17
+ */ export function computeBroadcastScopes(input) {
18
+ const { resolvedRule, senderGroupIDs } = input;
19
+ switch(resolvedRule.level){
20
+ case 'only_owner':
21
+ return [];
22
+ case 'anyone':
23
+ // Dedupe sender group IDs while preserving the input order.
24
+ return Array.from(new Set(senderGroupIDs));
25
+ case 'restricted':
26
+ {
27
+ const scopes = new Set();
28
+ for (const groupID of senderGroupIDs){
29
+ if (resolvedRule.allowedGroupIDs.has(groupID) || resolvedRule.allowedCircleParentGroupIDs.has(groupID) || resolvedRule.allowedDIDGroupIDs.has(groupID)) {
30
+ scopes.add(groupID);
31
+ }
32
+ }
33
+ return Array.from(scopes);
34
+ }
35
+ default:
36
+ {
37
+ const _exhaustive = resolvedRule.level;
38
+ throw new Error(`Unknown access level: ${String(_exhaustive)}`);
39
+ }
40
+ }
41
+ }
@@ -1,15 +1,15 @@
1
1
  import { Client } from '@enkaku/client';
2
2
  import type { ClientTransportOf } from '@enkaku/protocol';
3
- import type { Runtime } from '@enkaku/runtime';
4
- import type { SigningIdentity } from '@enkaku/token';
3
+ import type { SigningIdentity } from '@kokuin/token';
5
4
  import type { Logger } from '@kubun/logger';
5
+ import type { Runtime } from '@sozai/runtime';
6
6
  import type { SyncDirection, SyncProtocol } from '../protocol.js';
7
7
  /**
8
8
  * Provider for sync-specific transport to a peer's sync server.
9
9
  * The p2p plugin exposes this via `createSyncTransport()`.
10
10
  */
11
11
  export type SyncTransportProvider = {
12
- createSyncTransport: (signal?: AbortSignal) => ClientTransportOf<SyncProtocol>;
12
+ createSyncTransport: (signal?: AbortSignal) => ClientTransportOf<SyncProtocol> | Promise<ClientTransportOf<SyncProtocol>>;
13
13
  };
14
14
  export type SyncClientParams = {
15
15
  runtime: Runtime;
@@ -62,6 +62,8 @@ export declare class SyncClient {
62
62
  hlc: string;
63
63
  mutation_jwt: string;
64
64
  }>;
65
+ /** Delegation JWTs authorizing the caller for non-owned scopes. */
66
+ delegationTokens?: Array<string>;
65
67
  }): Promise<{
66
68
  mutationJWTs: Array<string>;
67
69
  divergentBuckets: number;