@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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +12 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +122 -38
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +7 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +69 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +56 -0
- package/lib/groups/group-health-monitor.js +139 -0
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +29 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +4 -8
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +28 -41
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +29 -13
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +8 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +471 -2
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +73 -0
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +12 -0
- package/lib/sync/errors.js +19 -0
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.d.ts +3 -22
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +19 -0
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +8 -1
- package/lib/sync/peer-registry.js +107 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +5 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +41 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +21 -0
- package/package.json +48 -41
- package/lib/groups/broadcast-service.d.ts +0 -189
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -32
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -67
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -33
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -127
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
|
@@ -1 +1,182 @@
|
|
|
1
|
-
import{getGraphStore
|
|
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
|
+
}
|
package/lib/sync/merkle-tree.js
CHANGED
|
@@ -1 +1,120 @@
|
|
|
1
|
-
import{blake3
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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;
|