@kubun/plugin-p2p 0.10.1 → 0.12.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 +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +55 -0
- package/lib/groups/admin-roster.js +69 -0
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +155 -1
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -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 +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -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 +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +704 -22
- package/lib/types.js +1 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.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 -89
- 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 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- 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
|
@@ -0,0 +1,1216 @@
|
|
|
1
|
+
import { Client } from '@enkaku/client';
|
|
2
|
+
import { ClientTransport } from '@enkaku/http-fetch';
|
|
3
|
+
import { normalizeDID } from '@kokuin/token';
|
|
4
|
+
import { getGraphStore } from '@kubun/store-graph';
|
|
5
|
+
import { getP2PStore } from '@kubun/store-p2p';
|
|
6
|
+
import { readGroupAnchor } from '@kumiai/mls';
|
|
7
|
+
import { blake3 } from '@noble/hashes/blake3.js';
|
|
8
|
+
import { GraphQLError } from 'graphql';
|
|
9
|
+
import { signAccessDefaultSet } from '../groups/access-default-token.js';
|
|
10
|
+
import { applyAccessDefaultSetFrame } from '../groups/broadcast.js';
|
|
11
|
+
import { foldGroupSettings } from '../groups/circle-projection.js';
|
|
12
|
+
import { awaitControlRequestSettled } from '../groups/control-request.js';
|
|
13
|
+
import { resolveJoinRequestDID } from '../groups/join-request-identity.js';
|
|
14
|
+
import { SyncAccessDeniedError } from '../sync/errors.js';
|
|
15
|
+
import { createGroupContext } from './group.js';
|
|
16
|
+
import { createJoinContext } from './join.js';
|
|
17
|
+
import { requireGroupAdmin } from './require-admin.js';
|
|
18
|
+
import { toISO } from './types.js';
|
|
19
|
+
/**
|
|
20
|
+
* Deterministic name marking the circle a prior `sharePeerGroup` created.
|
|
21
|
+
*
|
|
22
|
+
* One share circle per (group, SHARER), not per peer. A per-peer circle put a
|
|
23
|
+
* new circle in the model's read default on every admission, and that default is
|
|
24
|
+
* never widened once it exists — so the second device admitted to a group landed
|
|
25
|
+
* outside the ACL and could read nothing by model, while the admission reported
|
|
26
|
+
* a share. Sharing into one circle means the circle the first share named is
|
|
27
|
+
* already there, and nothing has to widen an ACL to admit a device.
|
|
28
|
+
*
|
|
29
|
+
* Keyed on the sharer because sharing is per-direction: each device owns the
|
|
30
|
+
* circle for what IT sends, and two admins deriving one id would merge two
|
|
31
|
+
* access scopes (see {@link shareRowID}).
|
|
32
|
+
*/ function shareCircleName(ownerDID) {
|
|
33
|
+
return `peer-share:${ownerDID}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* How long the re-invite path waits for a stale leaf's eviction to land before
|
|
37
|
+
* refusing the invite.
|
|
38
|
+
*
|
|
39
|
+
* Sized for the commit lane, not for a local write: the Remove goes to the
|
|
40
|
+
* group's commit log and can be rebased by a concurrent commit before it is
|
|
41
|
+
* accepted, so the bound has to cover several attempts plus their round-trips.
|
|
42
|
+
* It is a refusal deadline, not a latency target — a rejoin that waits this long
|
|
43
|
+
* has hit a real fault, and the caller can retry.
|
|
44
|
+
*/ const EVICT_STALE_LEAF_TIMEOUT = 30_000;
|
|
45
|
+
/**
|
|
46
|
+
* How long one `sharePeerGroup` step waits for the commit carrying its entry to
|
|
47
|
+
* be enacted. A hub round trip inside an interactive share flow — generous
|
|
48
|
+
* enough that a healthy hub never trips it, short enough that a share does not
|
|
49
|
+
* hang a UI. Applied per step, not as one shared budget.
|
|
50
|
+
*/ const SHARE_STEP_TIMEOUT_MS = 10_000;
|
|
51
|
+
/**
|
|
52
|
+
* Derive a stable UUID for one of the rows `sharePeerGroup` owns, from the
|
|
53
|
+
* (group, sharer) pair the share is made FROM and a per-row `slot`.
|
|
54
|
+
*
|
|
55
|
+
* DERIVED so a retried step re-mints the SAME subject — `circle.def`'s whole-value
|
|
56
|
+
* LWW folds that as an update — instead of a second circle and a second catalog.
|
|
57
|
+
* A second peer admitted to the same group derives the same pair, which is the
|
|
58
|
+
* point: it joins the circle the first share created.
|
|
59
|
+
*
|
|
60
|
+
* NOT caller-supplied, and NOT keyed on the group alone: two admins picking one
|
|
61
|
+
* id would merge two access scopes, and since a member subject embeds the circle
|
|
62
|
+
* id, both admins' grants would land in the merged scope with nothing thrown.
|
|
63
|
+
* Keying on the sharer is safe because a collision then MEANS this device's own
|
|
64
|
+
* share circle for that group.
|
|
65
|
+
*
|
|
66
|
+
* blake3 for the React Native / browser / node triple.
|
|
67
|
+
*/ function shareRowID(slot, groupID, ownerDID) {
|
|
68
|
+
const digest = blake3(new TextEncoder().encode(`kubun.peer-share:${slot}:${groupID}:${ownerDID}`), {
|
|
69
|
+
dkLen: 16
|
|
70
|
+
});
|
|
71
|
+
// Format as a v4-shaped UUID: every other id in the tree comes from
|
|
72
|
+
// `getRandomID()`, which yields v4, and columns and callers assume that shape.
|
|
73
|
+
// The version and variant nibbles are overwritten rather than derived, so the
|
|
74
|
+
// string is a well-formed UUID that carries 122 bits of the digest.
|
|
75
|
+
const bytes = Uint8Array.from(digest);
|
|
76
|
+
bytes[6] = bytes[6] & 0x0f | 0x40;
|
|
77
|
+
bytes[8] = bytes[8] & 0x3f | 0x80;
|
|
78
|
+
const hex = Array.from(bytes, (b)=>b.toString(16).padStart(2, '0')).join('');
|
|
79
|
+
return [
|
|
80
|
+
hex.slice(0, 8),
|
|
81
|
+
hex.slice(8, 12),
|
|
82
|
+
hex.slice(12, 16),
|
|
83
|
+
hex.slice(16, 20),
|
|
84
|
+
hex.slice(20, 32)
|
|
85
|
+
].join('-');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Models this device shares INTO a circle: its OWN read access-default rows
|
|
89
|
+
* whose `allowedCircles` names the circle. Write defaults are excluded — sharing
|
|
90
|
+
* is letting the circle READ your docs. Shared by the join-time open-circle
|
|
91
|
+
* picker and `setCircleSync`'s payload so the two computations cannot drift.
|
|
92
|
+
*/ function circleSharing(ownDefaults, circleID) {
|
|
93
|
+
return ownDefaults.filter((row)=>row.permissionType === 'read' && (row.allowedCircles ?? []).includes(circleID)).map((row)=>row.modelID);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Catalog reads for one flow, cached by id.
|
|
97
|
+
*
|
|
98
|
+
* The cache holds the PROMISE, not the awaited row: caching the row leaves a
|
|
99
|
+
* window where concurrent loads of the same id all miss and each issue their own
|
|
100
|
+
* read, so the cache silently stops working the moment a caller stops awaiting
|
|
101
|
+
* in sequence.
|
|
102
|
+
*/ function createCatalogLoader(graphStore) {
|
|
103
|
+
const cache = new Map();
|
|
104
|
+
return {
|
|
105
|
+
async prime (ids) {
|
|
106
|
+
const missing = ids.filter((id)=>!cache.has(id));
|
|
107
|
+
if (missing.length === 0) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const found = await graphStore.getCatalogs(missing);
|
|
111
|
+
// Seed misses too, so a catalog this device does not hold is remembered as
|
|
112
|
+
// absent rather than re-queried per lookup.
|
|
113
|
+
for (const id of missing){
|
|
114
|
+
cache.set(id, Promise.resolve(found.get(id)));
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
load (id) {
|
|
118
|
+
const cached = cache.get(id);
|
|
119
|
+
if (cached != null) {
|
|
120
|
+
return cached;
|
|
121
|
+
}
|
|
122
|
+
const pending = graphStore.getCatalog(id);
|
|
123
|
+
cache.set(id, pending);
|
|
124
|
+
return pending;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* True only when the circle has at least one catalog AND every one is active on
|
|
130
|
+
* this device — the post-state of a pull that activates all the circle's
|
|
131
|
+
* catalogs together (a pull-off deactivating them all inverts it). A zero-catalog
|
|
132
|
+
* circle has nothing to pull, so it is false.
|
|
133
|
+
*/ async function circlePulling(catalogIDs, loadCatalog) {
|
|
134
|
+
if (catalogIDs.length === 0) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
for (const catalogID of catalogIDs){
|
|
138
|
+
const catalog = await loadCatalog(catalogID);
|
|
139
|
+
if (catalog?.active !== 1) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
/** Own access-default rows keyed `modelID:permissionType`, for point lookups. */ function indexDefaults(rows) {
|
|
146
|
+
return new Map(rows.map((row)=>[
|
|
147
|
+
`${row.modelID}:${row.permissionType}`,
|
|
148
|
+
row
|
|
149
|
+
]));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The one circle row + local state → `CircleSyncState` mapping. The join-time
|
|
153
|
+
* picker, the `circleSync` query, and `setCircleSync`'s post-state all go through
|
|
154
|
+
* it, so what a mutation returns is exactly what a re-read reports.
|
|
155
|
+
*/ async function circleSyncState(circle, ownDefaults, loadCatalog) {
|
|
156
|
+
return {
|
|
157
|
+
id: circle.id,
|
|
158
|
+
groupID: circle.group_id,
|
|
159
|
+
name: circle.name,
|
|
160
|
+
description: circle.description,
|
|
161
|
+
catalogIDs: circle.catalog_ids,
|
|
162
|
+
open: circle.open === 1,
|
|
163
|
+
pulling: await circlePulling(circle.catalog_ids, loadCatalog),
|
|
164
|
+
sharing: circleSharing(ownDefaults, circle.id)
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Well-known protocol names advertised in a peer's `/info`. `connectPeer`
|
|
169
|
+
* requires both: the sync protocol so a later `syncPeer` can reach the data
|
|
170
|
+
* leg, and the peer protocol to run `peer/groups` (and the dance) now. These
|
|
171
|
+
* are the default registration names; a peer that advertises the sync protocol
|
|
172
|
+
* under a custom path is not discoverable by this check.
|
|
173
|
+
*/ const SYNC_PROTOCOL_NAME = 'sync';
|
|
174
|
+
const PEER_PROTOCOL_NAME = 'peer';
|
|
175
|
+
function isPeerInfoResponse(value) {
|
|
176
|
+
if (value == null || typeof value !== 'object') return false;
|
|
177
|
+
const candidate = value;
|
|
178
|
+
return typeof candidate.did === 'string' && typeof candidate.protocols === 'object';
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Fresh-only read-default classification over the exact models the user chose to
|
|
182
|
+
* send. A model with no existing read default is granted (a fresh row naming the
|
|
183
|
+
* circles, written through the owner-signed executor callback); an existing
|
|
184
|
+
* default that already names every circle is `MODEL_PRE_EXISTING`; one that is
|
|
185
|
+
* missing any is `DOCUMENT_ONLY` — never widened. Only listed models are
|
|
186
|
+
* classified, so every entry maps to a deliberate user selection.
|
|
187
|
+
*/ async function classifyModelDefaults(params) {
|
|
188
|
+
const { graphStore, ownerDID, models, circleIDs, setReadDefault } = params;
|
|
189
|
+
// One list read for every model's current rule. The grants below write only
|
|
190
|
+
// models that HAVE no rule, and each model is its own row, so no write here
|
|
191
|
+
// invalidates another model's snapshot.
|
|
192
|
+
const existingByKey = indexDefaults(await graphStore.listUserModelAccessDefaults(ownerDID));
|
|
193
|
+
const modelAccess = [];
|
|
194
|
+
for (const modelID of models){
|
|
195
|
+
const existing = existingByKey.get(`${modelID}:read`);
|
|
196
|
+
if (existing == null) {
|
|
197
|
+
await setReadDefault(modelID);
|
|
198
|
+
modelAccess.push({
|
|
199
|
+
modelID,
|
|
200
|
+
access: 'MODEL_GRANTED'
|
|
201
|
+
});
|
|
202
|
+
} else if (circleIDs.every((circleID)=>(existing.allowedCircles ?? []).includes(circleID))) {
|
|
203
|
+
modelAccess.push({
|
|
204
|
+
modelID,
|
|
205
|
+
access: 'MODEL_PRE_EXISTING'
|
|
206
|
+
});
|
|
207
|
+
} else {
|
|
208
|
+
// A default exists without (all of) these circles — never widened.
|
|
209
|
+
modelAccess.push({
|
|
210
|
+
modelID,
|
|
211
|
+
access: 'DOCUMENT_ONLY'
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return modelAccess;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The inviter leg of the inbound dance, run on behalf of a verified caller by
|
|
219
|
+
* the `group/invite` peer handler. It designates NO circle itself: the joiner
|
|
220
|
+
* lands only in circles the inviter ALREADY designated — a `sharePeerGroup`
|
|
221
|
+
* pre-add, or an explicit `requestAddCircleMember` — which `collectInviteSeeds`
|
|
222
|
+
* snapshots from the ledger. Both of those land through an enacted commit, so
|
|
223
|
+
* by the time a designation is snapshottable here it is already durable. The
|
|
224
|
+
* invite runs as THIS device's own identity; the caller only supplies the group
|
|
225
|
+
* and its KeyPackage.
|
|
226
|
+
*/ export async function serveGroupInvite(deps, params) {
|
|
227
|
+
const { callerDID, groupID, joinRequest } = params;
|
|
228
|
+
// The caller may only pull an invite for itself: the signed procedure payload
|
|
229
|
+
// binds `callerDID`, so refuse a join request naming a different recipient — a
|
|
230
|
+
// trusted peer must not be able to inject a third party into the group.
|
|
231
|
+
//
|
|
232
|
+
// Compared against the KeyPackage's own credential rather than the request's
|
|
233
|
+
// `did` field, so this binds the envelope to the identity that will actually
|
|
234
|
+
// hold the MLS leaf. Comparing the plaintext field would let a caller present
|
|
235
|
+
// its own DID beside someone else's KeyPackage and pass a check that looks
|
|
236
|
+
// like it verified something.
|
|
237
|
+
const credentialDID = resolveJoinRequestDID(joinRequest);
|
|
238
|
+
if (normalizeDID(credentialDID) !== normalizeDID(callerDID)) {
|
|
239
|
+
throw new Error('group/invite: join request DID does not match the authenticated caller');
|
|
240
|
+
}
|
|
241
|
+
const groupCtx = createGroupContext({
|
|
242
|
+
viewerDID: deps.identity.id
|
|
243
|
+
}, deps);
|
|
244
|
+
// A caller already in the MLS roster is a joiner that lost its local group
|
|
245
|
+
// state — re-adding it directly would fail (MLS rejects an Add for a current
|
|
246
|
+
// member). Its DID is signature-verified by the peer handler, so a re-join
|
|
247
|
+
// request from the same principal cannot widen access: evict the stale leaf
|
|
248
|
+
// and fall through to a fresh Add + Welcome (key rotation via the new
|
|
249
|
+
// KeyPackage) to restore the joiner's liveness.
|
|
250
|
+
const staleLeafIndex = await deps.registry.readHandle(groupID, (handle)=>handle.findMemberLeafIndex(callerDID), {
|
|
251
|
+
stores: deps.stores
|
|
252
|
+
});
|
|
253
|
+
if (staleLeafIndex != null) {
|
|
254
|
+
deps.logger.warn('group/invite: re-inviting existing member, evicting stale roster entry', {
|
|
255
|
+
groupID,
|
|
256
|
+
callerDID
|
|
257
|
+
});
|
|
258
|
+
// The eviction is AWAITED, not fired and forgotten: the Add below frames
|
|
259
|
+
// against whatever handle it reads, so it must not be built until the Remove
|
|
260
|
+
// has advanced this device's epoch past the stale leaf — otherwise existing
|
|
261
|
+
// members receive an Add against an epoch the Remove is still moving. This
|
|
262
|
+
// handler is not inside the engine's write transaction, so it can wait.
|
|
263
|
+
const request = await groupCtx.requestRemoveMember({
|
|
264
|
+
groupID,
|
|
265
|
+
memberDID: callerDID
|
|
266
|
+
});
|
|
267
|
+
const settled = await awaitControlRequestSettled({
|
|
268
|
+
stores: deps.stores,
|
|
269
|
+
emitter: deps.emitter,
|
|
270
|
+
requestID: request.id,
|
|
271
|
+
timeoutMs: EVICT_STALE_LEAF_TIMEOUT
|
|
272
|
+
});
|
|
273
|
+
if (settled.status !== 'committed') {
|
|
274
|
+
throw new Error(`group/invite: evicting the stale roster entry did not land (${settled.status})`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const inviteResult = await groupCtx.requestInvite({
|
|
278
|
+
groupID,
|
|
279
|
+
joinRequest
|
|
280
|
+
});
|
|
281
|
+
if (inviteResult.invitePayload == null) {
|
|
282
|
+
// The Add commit is still parked (this device's hub is offline), so there is
|
|
283
|
+
// no Welcome to hand back yet — the joiner cannot join until it lands. Surface
|
|
284
|
+
// the pending state as an error the caller retries, rather than passing a null
|
|
285
|
+
// payload downstream as if the invite had committed.
|
|
286
|
+
throw new Error(`group/invite: the add commit is still pending (${inviteResult.request.status}) — retry once the inviter is back online`);
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
invitePayload: inviteResult.invitePayload
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Apply the access-default rows a verified caller pushed for itself, run by the
|
|
294
|
+
* `group/push-control` peer handler. The source is UNTRUSTED, so the rows apply
|
|
295
|
+
* sender-bound — a row is kept only when its owner equals the authenticated
|
|
296
|
+
* caller, on top of the owner-equals-token-issuer check
|
|
297
|
+
* `applyAccessDefaultSetFrame` already enforces. A caller therefore cannot
|
|
298
|
+
* inject another owner's sharing policy.
|
|
299
|
+
*
|
|
300
|
+
* The caller must additionally be a member of the named group per THIS device's
|
|
301
|
+
* own membership view. The other remote ledger-ingest path rides the group's hub
|
|
302
|
+
* topic, whose MLS derivation is an implicit membership perimeter; this
|
|
303
|
+
* procedure is reachable by any accepted peer, so the perimeter is restated
|
|
304
|
+
* here. Fail-closed: an unknown group reads as no membership.
|
|
305
|
+
*/ export async function servePushControl(deps, params) {
|
|
306
|
+
const { callerDID, groupID, accessDefaults } = params;
|
|
307
|
+
const [p2pStore, graphStore] = await Promise.all([
|
|
308
|
+
getP2PStore(deps.stores),
|
|
309
|
+
getGraphStore(deps.stores)
|
|
310
|
+
]);
|
|
311
|
+
if (!await p2pStore.isGroupMember(groupID, callerDID)) {
|
|
312
|
+
throw new SyncAccessDeniedError('not a group member', `Push control denied: ${callerDID} is not a member of group ${groupID}`);
|
|
313
|
+
}
|
|
314
|
+
const callerNormalized = normalizeDID(callerDID);
|
|
315
|
+
let accessDefaultsApplied = 0;
|
|
316
|
+
for (const row of accessDefaults){
|
|
317
|
+
// Caller-binding: a caller may push only its OWN sharing policy. Drop a row
|
|
318
|
+
// naming a foreign owner before the token check, so a trusted peer cannot
|
|
319
|
+
// relay a third party's rows into this device.
|
|
320
|
+
if (normalizeDID(row.ownerDID) !== callerNormalized) {
|
|
321
|
+
deps.logger.warn('group/push-control: dropped access-default not owned by the caller', {
|
|
322
|
+
groupID,
|
|
323
|
+
ownerDID: row.ownerDID,
|
|
324
|
+
callerDID
|
|
325
|
+
});
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (await applyAccessDefaultSetFrame({
|
|
329
|
+
graphStore,
|
|
330
|
+
frame: row,
|
|
331
|
+
...deps.logger != null ? {
|
|
332
|
+
logger: deps.logger
|
|
333
|
+
} : {},
|
|
334
|
+
groupID
|
|
335
|
+
})) {
|
|
336
|
+
accessDefaultsApplied++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
accessDefaultsApplied
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Designate one peer's access to one group: resolve or create the group, fold
|
|
345
|
+
* the commit-hub designation, find or create the share circle, admit both DIDs,
|
|
346
|
+
* and write the catalog + read defaults the share's scope rests on.
|
|
347
|
+
*
|
|
348
|
+
* Local state only — no peer connection is read or written. Whatever follows a
|
|
349
|
+
* designation (the MLS dance, registering the sync peer) belongs to the caller,
|
|
350
|
+
* so admitting a peer without a live connection reuses this rather than copying
|
|
351
|
+
* it: an invite payload snapshots exactly what these steps wrote, so a second
|
|
352
|
+
* copy of the seeding order drifts into a joiner landing in no circle, silently.
|
|
353
|
+
*
|
|
354
|
+
* The steps are ORDER-dependent — each reads what an earlier one wrote — so the
|
|
355
|
+
* sequence below is the contract, not an arrangement.
|
|
356
|
+
*/ export async function designatePeerAccess(deps, params) {
|
|
357
|
+
const { groupCtx, selfDID, peerDID, target, operation, sendModels, receiveActivate } = params;
|
|
358
|
+
const [p2pStore, graphStore] = await Promise.all([
|
|
359
|
+
getP2PStore(deps.stores),
|
|
360
|
+
getGraphStore(deps.stores)
|
|
361
|
+
]);
|
|
362
|
+
// 1. Resolve or create the group. Existing-by-ID is the idempotent reuse;
|
|
363
|
+
// the `name` path always mints a new group (a retry uses the groupID path).
|
|
364
|
+
const group = 'name' in target ? await groupCtx.create({
|
|
365
|
+
name: target.name
|
|
366
|
+
}) : await (async ()=>{
|
|
367
|
+
const existing = await groupCtx.get(target.groupID);
|
|
368
|
+
if (existing == null) {
|
|
369
|
+
throw new GraphQLError(`${operation.name}: group ${target.groupID} not found`);
|
|
370
|
+
}
|
|
371
|
+
return existing;
|
|
372
|
+
})();
|
|
373
|
+
const groupID = group.id;
|
|
374
|
+
// Admin-only gate. Steps 2-3 mint circle-def and circle-member ledger
|
|
375
|
+
// entries whose folds admit only a ledger admin's issuance; a non-admin
|
|
376
|
+
// caller's entries are dropped on every peer, and the drop surfaces frames
|
|
377
|
+
// later as a null projection read-back. Reject here — before any append.
|
|
378
|
+
// The `name` path is admin by construction (creating a group mirrors the
|
|
379
|
+
// fresh handle's roster, which names the creator admin from the genesis
|
|
380
|
+
// anchor), so the check is a tautology there and never false-denies a
|
|
381
|
+
// fresh group.
|
|
382
|
+
const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
|
|
383
|
+
stores: deps.stores
|
|
384
|
+
});
|
|
385
|
+
if (anchor == null) {
|
|
386
|
+
throw new GraphQLError(`${operation.name}: group ${groupID} has no genesis anchor`);
|
|
387
|
+
}
|
|
388
|
+
await requireGroupAdmin({
|
|
389
|
+
store: p2pStore,
|
|
390
|
+
groupID,
|
|
391
|
+
did: selfDID,
|
|
392
|
+
action: operation.adminAction
|
|
393
|
+
});
|
|
394
|
+
// Each step below only ASKS — an entry lands when its commit is enacted — and
|
|
395
|
+
// each later step reads what an earlier one wrote, most sharply the invite the
|
|
396
|
+
// caller mints afterwards, which snapshots the circle and the ledger into its
|
|
397
|
+
// payload. So every settle is awaited; without that the joiner arrives in no
|
|
398
|
+
// circle and nothing throws. Awaiting a settle is only safe outside an engine
|
|
399
|
+
// write transaction, so every mutation field reaching this function must be
|
|
400
|
+
// listed in `nonTransactionalMutationFields` — `sharePeerGroup` is — or the
|
|
401
|
+
// wait deadlocks a single-connection SQLite adapter.
|
|
402
|
+
//
|
|
403
|
+
// Per-step timeout rather than one shared budget, so the throw names the step
|
|
404
|
+
// that stalled — the raw settle error names only an internal request id.
|
|
405
|
+
const settleStep = async (step, requestID)=>{
|
|
406
|
+
let settled;
|
|
407
|
+
try {
|
|
408
|
+
settled = await groupCtx.awaitSettle(requestID, SHARE_STEP_TIMEOUT_MS);
|
|
409
|
+
} catch (cause) {
|
|
410
|
+
// biome-ignore lint/style/useErrorCause: `originalError` is GraphQLError's own cause channel
|
|
411
|
+
throw new GraphQLError(`${operation.name}: ${step} did not land within ${SHARE_STEP_TIMEOUT_MS}ms — the group may be partially configured; re-run to converge`, {
|
|
412
|
+
originalError: cause
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
// `no_op` is a success: it means the state the step asked for already
|
|
416
|
+
// held, which is exactly what a re-run should find.
|
|
417
|
+
if (settled.status !== 'committed' && settled.status !== 'no_op') {
|
|
418
|
+
throw new GraphQLError(`${operation.name}: ${step} settled ${settled.status}${settled.error == null ? '' : ` — ${settled.error}`}`);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
// 1b. Promote a locally-designated commit hub to a FOLDED designation before
|
|
422
|
+
// the invite is minted. The bare bind mutation auto-designates a hub on THIS
|
|
423
|
+
// device only, so a co-member would fall back to its own first-bound hub and
|
|
424
|
+
// the group could fork; sharing is the first moment a co-member appears, and
|
|
425
|
+
// the entry must land before the invite step snapshots the ledger. Authors
|
|
426
|
+
// ONLY when a hub is designated locally AND none is folded yet — the same
|
|
427
|
+
// check reprojection reads — so a second share authors nothing.
|
|
428
|
+
const localCommitHub = await p2pStore.getCommitHub(groupID);
|
|
429
|
+
if (localCommitHub != null) {
|
|
430
|
+
const ledger = await deps.registry.readHandle(groupID, (handle)=>handle.ledger, {
|
|
431
|
+
stores: deps.stores
|
|
432
|
+
});
|
|
433
|
+
if (foldGroupSettings(ledger, groupID, anchor)?.commitHub == null) {
|
|
434
|
+
const request = await groupCtx.requestUpdate({
|
|
435
|
+
groupID,
|
|
436
|
+
commitHub: localCommitHub.server_did != null ? {
|
|
437
|
+
url: localCommitHub.url,
|
|
438
|
+
serverDID: localCommitHub.server_did
|
|
439
|
+
} : {
|
|
440
|
+
url: localCommitHub.url
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
await settleStep('designating the commit hub', request.id);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// 2. Find or create the share circle. Its id is DERIVED from the (group,
|
|
447
|
+
// sharer) pair, so a run that timed out and then landed is rediscovered
|
|
448
|
+
// exactly rather than duplicated, and a second peer joins the circle the
|
|
449
|
+
// first share created — the name is a human-readable marker only, and is
|
|
450
|
+
// no longer what the lookup keys on.
|
|
451
|
+
const circleName = shareCircleName(selfDID);
|
|
452
|
+
const circleID = shareRowID('circle', groupID, selfDID);
|
|
453
|
+
const existingCircle = await p2pStore.getCircle(circleID);
|
|
454
|
+
if (existingCircle == null) {
|
|
455
|
+
const created = await groupCtx.requestCreateCircle({
|
|
456
|
+
groupID,
|
|
457
|
+
circleID,
|
|
458
|
+
name: circleName,
|
|
459
|
+
description: 'Share circle created by sharePeerGroup'
|
|
460
|
+
});
|
|
461
|
+
await settleStep('creating the share circle', created.request.id);
|
|
462
|
+
}
|
|
463
|
+
// 3. Add both DIDs to the circle before the invite, so the invite payload
|
|
464
|
+
// seeds the joiner's circle membership. Skip a DID already an ACTIVE member:
|
|
465
|
+
// `getCircleMember` answers with tombstoned rows too, and treating one as
|
|
466
|
+
// present skips the re-add, so a peer removed from this circle once is shared
|
|
467
|
+
// with again and still cannot read.
|
|
468
|
+
for (const did of [
|
|
469
|
+
selfDID,
|
|
470
|
+
peerDID
|
|
471
|
+
]){
|
|
472
|
+
const member = await p2pStore.getCircleMember(circleID, did);
|
|
473
|
+
if (member == null || member.removed_at_hlc != null) {
|
|
474
|
+
const request = await groupCtx.requestAddCircleMember({
|
|
475
|
+
circleID,
|
|
476
|
+
memberDID: did,
|
|
477
|
+
role: 'member'
|
|
478
|
+
});
|
|
479
|
+
await settleStep(`adding ${did} to the share circle`, request.id);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// 4. Access legs come ONLY from an explicit `send`. With no send, the share
|
|
483
|
+
// is plumbing only: no catalog, no default rows. The catalog + defaults are
|
|
484
|
+
// written BEFORE the dance so they travel in the invite payload's seeds.
|
|
485
|
+
const circleRow = await p2pStore.getCircle(circleID);
|
|
486
|
+
let catalogID = circleRow?.catalog_ids?.[0] ?? null;
|
|
487
|
+
let modelAccess = [];
|
|
488
|
+
if (sendModels != null) {
|
|
489
|
+
if (catalogID == null) {
|
|
490
|
+
// Derived for the same reason as the circle: a run that created the
|
|
491
|
+
// catalog but never landed the attachment must reuse it, not mint a
|
|
492
|
+
// second one. The catalog row itself is a plain store write, so the
|
|
493
|
+
// probe is the row, not a request.
|
|
494
|
+
const derivedCatalogID = shareRowID('catalog', groupID, selfDID);
|
|
495
|
+
const existingCatalog = await graphStore.getCatalog(derivedCatalogID);
|
|
496
|
+
if (existingCatalog == null) {
|
|
497
|
+
const filterCriteria = {
|
|
498
|
+
models: sendModels,
|
|
499
|
+
circles: [
|
|
500
|
+
circleID
|
|
501
|
+
]
|
|
502
|
+
};
|
|
503
|
+
await groupCtx.createCatalog({
|
|
504
|
+
catalogID: derivedCatalogID,
|
|
505
|
+
name: circleName,
|
|
506
|
+
filterCriteria: JSON.stringify(filterCriteria)
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
catalogID = derivedCatalogID;
|
|
510
|
+
const request = await groupCtx.requestUpdateCircle({
|
|
511
|
+
circleID,
|
|
512
|
+
catalogIDs: [
|
|
513
|
+
catalogID
|
|
514
|
+
]
|
|
515
|
+
});
|
|
516
|
+
await settleStep('attaching the catalog to the share circle', request.id);
|
|
517
|
+
// The catalog is created active (own-catalog default); activation is now
|
|
518
|
+
// the explicit receive opt-in, so demote it to known unless the sharer
|
|
519
|
+
// opted into pulling the share scope.
|
|
520
|
+
if (!receiveActivate) {
|
|
521
|
+
await graphStore.setCatalogActive(catalogID, false);
|
|
522
|
+
}
|
|
523
|
+
} else {
|
|
524
|
+
// The group's one catalog already exists, and its criteria are what every
|
|
525
|
+
// member of the share circle pulls. A model outside it would be granted a
|
|
526
|
+
// read default here and named by no catalog — a share reported as
|
|
527
|
+
// succeeding that moves nothing — so widen the catalog to cover it.
|
|
528
|
+
//
|
|
529
|
+
// Through `updateCatalog` for its two guarantees: it refuses a catalog
|
|
530
|
+
// this device does not own, and it re-signs the record. Without the
|
|
531
|
+
// re-sign the widened catalog would stop seeding into invites entirely.
|
|
532
|
+
//
|
|
533
|
+
// Group-wide by nature — every device that activated this catalog now
|
|
534
|
+
// pulls the added models too. That is bounded by each owner's own read
|
|
535
|
+
// rules, which the catalog selects within and never grants: an undeclared
|
|
536
|
+
// model defaults to `only_owner` and is not broadcast at all. And it is a
|
|
537
|
+
// deliberate admin act, since `sharePeerGroup` is admin-gated.
|
|
538
|
+
const existingCatalog = await graphStore.getCatalog(catalogID);
|
|
539
|
+
if (existingCatalog == null) {
|
|
540
|
+
throw new GraphQLError(`sharePeerGroup: the share circle names catalog ${catalogID}, which this device does not hold`);
|
|
541
|
+
}
|
|
542
|
+
const covered = existingCatalog.filter_criteria?.models ?? [];
|
|
543
|
+
const uncovered = sendModels.filter((modelID)=>!covered.includes(modelID));
|
|
544
|
+
if (uncovered.length > 0) {
|
|
545
|
+
await groupCtx.updateCatalog({
|
|
546
|
+
catalogID,
|
|
547
|
+
filterCriteria: JSON.stringify({
|
|
548
|
+
...existingCatalog.filter_criteria,
|
|
549
|
+
models: [
|
|
550
|
+
...covered,
|
|
551
|
+
...uncovered
|
|
552
|
+
]
|
|
553
|
+
})
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
if (receiveActivate) {
|
|
557
|
+
await graphStore.setCatalogActive(catalogID, true);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Fresh-only read defaults for exactly the user's selection.
|
|
561
|
+
modelAccess = await classifyModelDefaults({
|
|
562
|
+
graphStore,
|
|
563
|
+
ownerDID: selfDID,
|
|
564
|
+
models: sendModels,
|
|
565
|
+
circleIDs: [
|
|
566
|
+
circleID
|
|
567
|
+
],
|
|
568
|
+
setReadDefault: (modelID)=>params.setReadDefaultForCircle(modelID, circleID)
|
|
569
|
+
});
|
|
570
|
+
} else if (receiveActivate && catalogID != null) {
|
|
571
|
+
// No send, but the sharer opts into pulling an already-configured group's
|
|
572
|
+
// existing catalogs.
|
|
573
|
+
await graphStore.setCatalogsActive(circleRow?.catalog_ids ?? [], true);
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
group,
|
|
577
|
+
modelAccess,
|
|
578
|
+
catalogID
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
export function createPeerContext(ctx, deps) {
|
|
582
|
+
return {
|
|
583
|
+
connect: async (url)=>{
|
|
584
|
+
// Discover the peer: GET `/info`, which advertises the device DID and the
|
|
585
|
+
// protocol paths it serves. A server-side outbound fetch to a
|
|
586
|
+
// caller-supplied URL is accepted — the caller is the device operator.
|
|
587
|
+
const infoURL = new URL('/info', url).toString();
|
|
588
|
+
let info;
|
|
589
|
+
try {
|
|
590
|
+
const response = await deps.runtime.fetch(infoURL);
|
|
591
|
+
if (!response.ok) {
|
|
592
|
+
throw new Error(`peer /info responded with status ${response.status}`);
|
|
593
|
+
}
|
|
594
|
+
const body = await response.json();
|
|
595
|
+
if (!isPeerInfoResponse(body)) {
|
|
596
|
+
throw new Error('peer /info returned an unexpected shape');
|
|
597
|
+
}
|
|
598
|
+
info = body;
|
|
599
|
+
} catch (error) {
|
|
600
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
601
|
+
throw new Error(`connectPeer: failed to reach ${infoURL}: ${message}`, {
|
|
602
|
+
cause: error
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
// Both protocols must be advertised: the peer leg (used now) and the sync
|
|
606
|
+
// leg (used by a later `syncPeer`).
|
|
607
|
+
const peerPath = info.protocols[PEER_PROTOCOL_NAME];
|
|
608
|
+
const syncPath = info.protocols[SYNC_PROTOCOL_NAME];
|
|
609
|
+
if (peerPath == null || syncPath == null) {
|
|
610
|
+
throw new Error(`connectPeer: ${url} does not advertise the required protocols (need '${SYNC_PROTOCOL_NAME}' and '${PEER_PROTOCOL_NAME}')`);
|
|
611
|
+
}
|
|
612
|
+
// Resolved against the URL the operator gave, and pinned to its origin. The
|
|
613
|
+
// `/info` body is an unauthenticated fetch, so an absolute URL in it would
|
|
614
|
+
// silently move the connection — and the sync leg, which `addPeer`
|
|
615
|
+
// persists — to a host the operator never named. A protocol entry is a
|
|
616
|
+
// path on the peer being connected to; anything else is refused rather
|
|
617
|
+
// than followed.
|
|
618
|
+
const resolveEndpoint = (path, protocol)=>{
|
|
619
|
+
const base = new URL(url);
|
|
620
|
+
const resolved = new URL(path, base);
|
|
621
|
+
if (resolved.origin !== base.origin) {
|
|
622
|
+
throw new Error(`connectPeer: ${url} advertises '${protocol}' at ${resolved.origin}, a different origin`);
|
|
623
|
+
}
|
|
624
|
+
return resolved.toString();
|
|
625
|
+
};
|
|
626
|
+
const peerEndpoint = resolveEndpoint(peerPath, PEER_PROTOCOL_NAME);
|
|
627
|
+
const syncEndpoint = resolveEndpoint(syncPath, SYNC_PROTOCOL_NAME);
|
|
628
|
+
const transport = new ClientTransport({
|
|
629
|
+
url: peerEndpoint,
|
|
630
|
+
fetch: deps.runtime.fetch
|
|
631
|
+
});
|
|
632
|
+
const client = new Client({
|
|
633
|
+
runtime: deps.runtime,
|
|
634
|
+
identity: deps.identity,
|
|
635
|
+
logger: deps.logger.getChild('peer-client'),
|
|
636
|
+
serverID: info.did,
|
|
637
|
+
transport
|
|
638
|
+
});
|
|
639
|
+
let result;
|
|
640
|
+
try {
|
|
641
|
+
result = await client.request('peer/groups', {
|
|
642
|
+
param: {}
|
|
643
|
+
});
|
|
644
|
+
} catch (error) {
|
|
645
|
+
// Nothing has been stored yet — tear down the just-built connection so a
|
|
646
|
+
// failed connect leaves no live transport behind.
|
|
647
|
+
try {
|
|
648
|
+
await client.dispose();
|
|
649
|
+
} catch {
|
|
650
|
+
// best-effort
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
await transport.dispose();
|
|
654
|
+
} catch {
|
|
655
|
+
// best-effort
|
|
656
|
+
}
|
|
657
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
658
|
+
throw new Error(`connectPeer: peer/groups failed against ${peerEndpoint}: ${message}`, {
|
|
659
|
+
cause: error
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
// The DID this connection is keyed on has to be the DID it addresses.
|
|
663
|
+
// `info.did` is what every request on this client is sent to — `serverID`
|
|
664
|
+
// becomes the `aud` claim — and it is also what `syncEndpoint` was
|
|
665
|
+
// resolved beside; `result.did` is a field the remote wrote into its own
|
|
666
|
+
// `peer/groups` body. Neither is authenticated on its own: `/info` is a
|
|
667
|
+
// plain fetch. What this refuses is the two disagreeing, which would key
|
|
668
|
+
// the registry — and every grant a caller names off it — on one identity
|
|
669
|
+
// while the traffic is addressed to another.
|
|
670
|
+
if (normalizeDID(result.did) !== normalizeDID(info.did)) {
|
|
671
|
+
try {
|
|
672
|
+
await client.dispose();
|
|
673
|
+
} catch {
|
|
674
|
+
// best-effort
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
await transport.dispose();
|
|
678
|
+
} catch {
|
|
679
|
+
// best-effort
|
|
680
|
+
}
|
|
681
|
+
throw new Error(`connectPeer: ${url} answered peer/groups as ${result.did} but identifies as ${info.did}`);
|
|
682
|
+
}
|
|
683
|
+
// Hold the connection in memory keyed by the remote DID. A repeat connect
|
|
684
|
+
// to the same DID replaces this entry and disposes the prior transport.
|
|
685
|
+
await deps.peerConnections.set({
|
|
686
|
+
peerDID: result.did,
|
|
687
|
+
endpoint: url,
|
|
688
|
+
syncEndpoint,
|
|
689
|
+
client,
|
|
690
|
+
transport
|
|
691
|
+
});
|
|
692
|
+
return {
|
|
693
|
+
did: result.did,
|
|
694
|
+
endpoint: url,
|
|
695
|
+
groups: result.groups.map((group)=>({
|
|
696
|
+
id: group.id,
|
|
697
|
+
name: group.name
|
|
698
|
+
}))
|
|
699
|
+
};
|
|
700
|
+
},
|
|
701
|
+
sharePeerGroup: async (params)=>{
|
|
702
|
+
const { peerDID, groupID: inputGroupID, name, sendModels, receiveActivate } = params;
|
|
703
|
+
// `groupID` XOR `name`: share an existing group or create a new one. Checked
|
|
704
|
+
// here rather than in the designation: the two-optional-argument shape is
|
|
705
|
+
// this mutation's own, and the designation takes one resolved target. It
|
|
706
|
+
// runs BEFORE the connection check, so a malformed call is refused for
|
|
707
|
+
// being malformed whether or not a connection is held.
|
|
708
|
+
if (inputGroupID == null === (name == null)) {
|
|
709
|
+
throw new GraphQLError('sharePeerGroup requires exactly one of groupID (share existing) or name (create new)');
|
|
710
|
+
}
|
|
711
|
+
// The held connection is required — never auto-dial. A caller must run
|
|
712
|
+
// `connectPeer` first; nothing here is persisted to re-establish it.
|
|
713
|
+
// Checked before ANY designation: this is the connected path, so a caller
|
|
714
|
+
// with no connection must fail having changed nothing.
|
|
715
|
+
const connection = deps.peerConnections.get(peerDID);
|
|
716
|
+
if (connection == null) {
|
|
717
|
+
throw new GraphQLError(`sharePeerGroup: no active connection to ${peerDID} — call connectPeer first`);
|
|
718
|
+
}
|
|
719
|
+
const groupCtx = createGroupContext(ctx, deps);
|
|
720
|
+
const p2pStore = await getP2PStore(deps.stores);
|
|
721
|
+
// 0. Pull the peer's join request FIRST and bind it to the DID about to be
|
|
722
|
+
// granted access, before a single row is written.
|
|
723
|
+
//
|
|
724
|
+
// The two identities are derived independently: the circle grant names
|
|
725
|
+
// `peerDID`, the MLS recipient comes from the KeyPackage credential inside
|
|
726
|
+
// `prep.joinRequest`. Uncompared, a peer receives the group's keys under
|
|
727
|
+
// credential Y while this device grants circle read access to whatever X
|
|
728
|
+
// it asked for — the split leaf/membership-row identity
|
|
729
|
+
// `groups/join-request-identity.ts` forbids. Explicit circle membership
|
|
730
|
+
// alone satisfies `circleAccessPredicate`, so X is a real grant to a
|
|
731
|
+
// principal that never joined.
|
|
732
|
+
//
|
|
733
|
+
// Ahead of the designation for the reason `admitJoinRequest` states: a
|
|
734
|
+
// designation minted for a DID the invite then refuses leaves a share
|
|
735
|
+
// circle and catalog behind for a member that never joins. Costs one
|
|
736
|
+
// wasted KeyPackage when re-run against a group the peer already belongs
|
|
737
|
+
// to; the peer mints those on demand.
|
|
738
|
+
const prep = await connection.client.request('group/prepare-join', {
|
|
739
|
+
param: {}
|
|
740
|
+
});
|
|
741
|
+
const credentialDID = resolveJoinRequestDID(prep.joinRequest);
|
|
742
|
+
if (normalizeDID(credentialDID) !== normalizeDID(peerDID)) {
|
|
743
|
+
throw new GraphQLError(`sharePeerGroup: the peer's join request binds ${credentialDID}, not ${peerDID}`);
|
|
744
|
+
}
|
|
745
|
+
// Steps 1-4: designate the peer's access — the state the invite below
|
|
746
|
+
// snapshots. Deliberately not inlined here: any other way of admitting
|
|
747
|
+
// this peer must seed it identically.
|
|
748
|
+
const designated = await designatePeerAccess(deps, {
|
|
749
|
+
groupCtx,
|
|
750
|
+
selfDID: ctx.viewerDID,
|
|
751
|
+
peerDID,
|
|
752
|
+
target: name != null ? {
|
|
753
|
+
name
|
|
754
|
+
} : {
|
|
755
|
+
groupID: inputGroupID
|
|
756
|
+
},
|
|
757
|
+
operation: {
|
|
758
|
+
name: 'sharePeerGroup',
|
|
759
|
+
adminAction: 'share a group'
|
|
760
|
+
},
|
|
761
|
+
sendModels: sendModels ?? null,
|
|
762
|
+
receiveActivate,
|
|
763
|
+
setReadDefaultForCircle: params.setReadDefaultForCircle
|
|
764
|
+
});
|
|
765
|
+
const groupID = designated.group.id;
|
|
766
|
+
// 5. Two-phase MLS dance over the peer connection — skip entirely when the
|
|
767
|
+
// peer is already a group member (a retry after the dance completed). The
|
|
768
|
+
// lookup keys on the DID the credential above was bound to, so it can no
|
|
769
|
+
// longer miss on a peer that is in fact a member and re-run the dance.
|
|
770
|
+
const alreadyMember = await p2pStore.getGroupMember(groupID, peerDID);
|
|
771
|
+
if (alreadyMember == null) {
|
|
772
|
+
const inviteResult = await groupCtx.requestInvite({
|
|
773
|
+
groupID,
|
|
774
|
+
joinRequest: prep.joinRequest
|
|
775
|
+
});
|
|
776
|
+
if (inviteResult.invitePayload == null) {
|
|
777
|
+
// The Add commit is still parked (this device's hub is offline): there
|
|
778
|
+
// is no Welcome to complete the join with. Surface the pending state
|
|
779
|
+
// rather than handing a null payload to `group/complete-join`.
|
|
780
|
+
throw new GraphQLError(`sharePeerGroup: the invite commit is still pending (${inviteResult.request.status}) — retry once the hub is reachable`);
|
|
781
|
+
}
|
|
782
|
+
await connection.client.request('group/complete-join', {
|
|
783
|
+
param: {
|
|
784
|
+
invitePayload: inviteResult.invitePayload
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
// 6. Register the sync peer (upsert on peer_did). No initial sync — the
|
|
789
|
+
// sharer pulls via `syncPeer` once catalogs are active.
|
|
790
|
+
await deps.syncManager.addPeer({
|
|
791
|
+
config: {
|
|
792
|
+
peerDID,
|
|
793
|
+
endpoint: connection.syncEndpoint
|
|
794
|
+
},
|
|
795
|
+
stores: deps.stores
|
|
796
|
+
});
|
|
797
|
+
return designated;
|
|
798
|
+
},
|
|
799
|
+
admitJoinRequest: async (params)=>{
|
|
800
|
+
const { groupID, joinRequest, sendModels, receiveActivate } = params;
|
|
801
|
+
// Resolve the joiner BEFORE anything is written. A designation minted for a
|
|
802
|
+
// DID the invite then refuses would leave a share circle and a catalog
|
|
803
|
+
// behind for a member that never joins.
|
|
804
|
+
const peerDID = resolveJoinRequestDID(joinRequest);
|
|
805
|
+
// Ahead of the roster read below, not only ahead of the writes. The
|
|
806
|
+
// designation runs this same gate, so reaching it at all is already
|
|
807
|
+
// admin-only — but the roster read answers "is this DID in the group",
|
|
808
|
+
// and a non-admin holding any join request could otherwise ask that
|
|
809
|
+
// question and read the answer off which refusal came back.
|
|
810
|
+
await requireGroupAdmin({
|
|
811
|
+
store: await getP2PStore(deps.stores),
|
|
812
|
+
groupID,
|
|
813
|
+
did: ctx.viewerDID,
|
|
814
|
+
action: 'admit a member to the group'
|
|
815
|
+
});
|
|
816
|
+
// Where `serveGroupInvite` EVICTS a stale leaf for a DID already in the
|
|
817
|
+
// roster and re-adds it, this refuses. That eviction is justified there by
|
|
818
|
+
// the peer handler having signature-verified the caller; nothing verifies
|
|
819
|
+
// this request's author, so honouring the same branch would let a payload
|
|
820
|
+
// an attacker got in front of an admin remove a current member from the
|
|
821
|
+
// group — a denial of service authored by the admin who pasted it.
|
|
822
|
+
const existingLeafIndex = await deps.registry.readHandle(groupID, (handle)=>handle.findMemberLeafIndex(peerDID), {
|
|
823
|
+
stores: deps.stores
|
|
824
|
+
});
|
|
825
|
+
if (existingLeafIndex != null) {
|
|
826
|
+
throw new GraphQLError(`admitJoinRequest: ${peerDID} is already a member of group ${groupID} — refused rather than re-added, because nothing here verifies who authored the join request`);
|
|
827
|
+
}
|
|
828
|
+
const groupCtx = createGroupContext(ctx, deps);
|
|
829
|
+
// The same designation `sharePeerGroup` performs, on the same function, so
|
|
830
|
+
// an admitted peer is seeded identically whether or not a connection exists.
|
|
831
|
+
const designated = await designatePeerAccess(deps, {
|
|
832
|
+
groupCtx,
|
|
833
|
+
selfDID: ctx.viewerDID,
|
|
834
|
+
peerDID,
|
|
835
|
+
target: {
|
|
836
|
+
groupID
|
|
837
|
+
},
|
|
838
|
+
operation: {
|
|
839
|
+
name: 'admitJoinRequest',
|
|
840
|
+
adminAction: 'admit a member to the group'
|
|
841
|
+
},
|
|
842
|
+
sendModels: sendModels ?? null,
|
|
843
|
+
receiveActivate,
|
|
844
|
+
setReadDefaultForCircle: params.setReadDefaultForCircle
|
|
845
|
+
});
|
|
846
|
+
// Minted last, through the same request facade: the invite payload
|
|
847
|
+
// snapshots exactly what the designation above wrote.
|
|
848
|
+
const inviteResult = await groupCtx.requestInvite({
|
|
849
|
+
groupID: designated.group.id,
|
|
850
|
+
joinRequest
|
|
851
|
+
});
|
|
852
|
+
if (inviteResult.invitePayload == null) {
|
|
853
|
+
// The Add commit is still parked, so there is no Welcome to hand back.
|
|
854
|
+
// Surface the pending state rather than returning a null payload the
|
|
855
|
+
// operator would carry to a joiner that cannot use it.
|
|
856
|
+
throw new GraphQLError(`admitJoinRequest: the invite commit is still pending (${inviteResult.request.status}) — retry once this device's commit lane is reachable`);
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
...designated,
|
|
860
|
+
peerDID,
|
|
861
|
+
invitePayload: inviteResult.invitePayload
|
|
862
|
+
};
|
|
863
|
+
},
|
|
864
|
+
joinPeerGroup: async (params)=>{
|
|
865
|
+
const { peerDID, groupID } = params;
|
|
866
|
+
// The held connection is required — never auto-dial. A caller must run
|
|
867
|
+
// `connectPeer` first (and discover the group from its group list).
|
|
868
|
+
const connection = deps.peerConnections.get(peerDID);
|
|
869
|
+
if (connection == null) {
|
|
870
|
+
throw new GraphQLError(`joinPeerGroup: no active connection to ${peerDID} — call connectPeer first`);
|
|
871
|
+
}
|
|
872
|
+
const selfDID = ctx.viewerDID;
|
|
873
|
+
const [p2pStore, graphStore] = await Promise.all([
|
|
874
|
+
getP2PStore(deps.stores),
|
|
875
|
+
getGraphStore(deps.stores)
|
|
876
|
+
]);
|
|
877
|
+
// 1. Inbound MLS dance — skipped only when THIS device holds the full
|
|
878
|
+
// local group state (MLS state + own membership row): a retry after a
|
|
879
|
+
// prior completed join. A membership row WITHOUT MLS state means the
|
|
880
|
+
// device lost its state mid-dance; re-running the dance lets the inviter
|
|
881
|
+
// evict the stale roster entry and re-add with a fresh Welcome.
|
|
882
|
+
// `prepareRequest` mints + stashes the KeyPackage under `selfDID`; the
|
|
883
|
+
// remote `group/invite` returns the Welcome + seeds; `complete` joins MLS
|
|
884
|
+
// and folds the seeded control state (ledger/circles/access-defaults/
|
|
885
|
+
// catalogs) so circles project — and catalogs arrive KNOWN — at join.
|
|
886
|
+
const membershipRow = await p2pStore.getGroupMember(groupID, selfDID);
|
|
887
|
+
const mlsState = await deps.groupManager.loadGroup(deps.stores, groupID);
|
|
888
|
+
const alreadyJoined = membershipRow != null && mlsState != null;
|
|
889
|
+
if (!alreadyJoined) {
|
|
890
|
+
const joinCtx = createJoinContext(ctx, deps);
|
|
891
|
+
const prep = await joinCtx.prepareRequest();
|
|
892
|
+
const invite = await connection.client.request('group/invite', {
|
|
893
|
+
param: {
|
|
894
|
+
groupID,
|
|
895
|
+
joinRequest: prep.joinRequest
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
await joinCtx.complete(invite.invitePayload);
|
|
899
|
+
}
|
|
900
|
+
// 2. Register the sync peer (upsert on peer_did). No access writes, no
|
|
901
|
+
// activation, no initial sync — the user opts into what to send/receive
|
|
902
|
+
// afterward via the explicit surfaces (`setModelAccessDefaults` /
|
|
903
|
+
// `activateCatalogSync`), and `syncPeer` pulls only active-catalog scopes.
|
|
904
|
+
await deps.syncManager.addPeer({
|
|
905
|
+
config: {
|
|
906
|
+
peerDID,
|
|
907
|
+
endpoint: connection.syncEndpoint
|
|
908
|
+
},
|
|
909
|
+
stores: deps.stores
|
|
910
|
+
});
|
|
911
|
+
// 3. Ledger-catchup backstop: fold any entries appended after the invite
|
|
912
|
+
// snapshot was minted (also covers a member that missed broadcasts). MUST
|
|
913
|
+
// pass `quorum` — without it the gather blocks the full default window.
|
|
914
|
+
// Non-fatal: a backstop, not the primary seeding.
|
|
915
|
+
try {
|
|
916
|
+
await deps.requestLedgerCatchup(groupID, {
|
|
917
|
+
quorum: 1,
|
|
918
|
+
timeoutMs: 2000
|
|
919
|
+
});
|
|
920
|
+
} catch (error) {
|
|
921
|
+
deps.logger.warn('joinPeerGroup: ledger catch-up failed (non-fatal)', {
|
|
922
|
+
groupID,
|
|
923
|
+
error
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
// 4. Report the group's known catalogs (its circles' catalog IDs present
|
|
927
|
+
// locally) so the app can render the receive side of the share settings.
|
|
928
|
+
// Seeded known; the user activates later via `activateCatalogSync`. This
|
|
929
|
+
// leg runs OUTSIDE the alreadyJoined short-circuit and only reads, so a
|
|
930
|
+
// retry — including the alreadyJoined path — recomputes the same payload.
|
|
931
|
+
const circles = await p2pStore.listCirclesByGroup(groupID);
|
|
932
|
+
// The known-catalog scan and the open-circle `pulling` computation read the
|
|
933
|
+
// same catalog rows, so fetch every one the group's circles name in a
|
|
934
|
+
// single query and serve both from it.
|
|
935
|
+
const catalogs = createCatalogLoader(graphStore);
|
|
936
|
+
await catalogs.prime(circles.flatMap((circle)=>circle.catalog_ids));
|
|
937
|
+
const loadCatalog = catalogs.load;
|
|
938
|
+
const seenCatalogIDs = new Set();
|
|
939
|
+
const knownCatalogIDs = [];
|
|
940
|
+
for (const circle of circles){
|
|
941
|
+
for (const catalogID of circle.catalog_ids){
|
|
942
|
+
if (seenCatalogIDs.has(catalogID)) continue;
|
|
943
|
+
seenCatalogIDs.add(catalogID);
|
|
944
|
+
if (await loadCatalog(catalogID) != null) {
|
|
945
|
+
knownCatalogIDs.push(catalogID);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// 5. Open-circle picker: the group's OPEN circles, each carrying THIS
|
|
950
|
+
// device's current pull/share state — computed from local state alone,
|
|
951
|
+
// with no member-authored wire entry and no extra network round-trip.
|
|
952
|
+
// `pulling` is true only when the circle has catalogs and every one is
|
|
953
|
+
// active locally: the exact post-state a pull that activates all the
|
|
954
|
+
// circle's catalogs together leaves behind (a pull-off deactivating them
|
|
955
|
+
// all inverts it), so it round-trips with the pull toggle; a zero-catalog
|
|
956
|
+
// circle has nothing to pull and is false. `sharing` lists the models this
|
|
957
|
+
// device lets the circle read — its OWN read access-default rows naming the
|
|
958
|
+
// circle (write defaults are excluded, matching how the share toggle writes
|
|
959
|
+
// read defaults). Closed circles are omitted: belonging to one is a grant,
|
|
960
|
+
// not a user opt-in.
|
|
961
|
+
const ownDefaults = await graphStore.listUserModelAccessDefaults(selfDID);
|
|
962
|
+
const openCircles = [];
|
|
963
|
+
for (const circle of circles){
|
|
964
|
+
if (circle.open !== 1) continue;
|
|
965
|
+
openCircles.push(await circleSyncState(circle, ownDefaults, loadCatalog));
|
|
966
|
+
}
|
|
967
|
+
const groupRow = await p2pStore.getGroup(groupID);
|
|
968
|
+
if (groupRow == null) {
|
|
969
|
+
throw new GraphQLError(`joinPeerGroup: group ${groupID} not found after join`);
|
|
970
|
+
}
|
|
971
|
+
const group = {
|
|
972
|
+
id: groupRow.id,
|
|
973
|
+
name: groupRow.name,
|
|
974
|
+
description: groupRow.description,
|
|
975
|
+
createdBy: groupRow.created_by,
|
|
976
|
+
createdAt: toISO(groupRow.created_at)
|
|
977
|
+
};
|
|
978
|
+
return {
|
|
979
|
+
group,
|
|
980
|
+
knownCatalogIDs,
|
|
981
|
+
alreadyJoined,
|
|
982
|
+
openCircles
|
|
983
|
+
};
|
|
984
|
+
},
|
|
985
|
+
setCircleSync: async (params)=>{
|
|
986
|
+
const { circleID, pull, share } = params;
|
|
987
|
+
const selfDID = ctx.viewerDID;
|
|
988
|
+
const [p2pStore, graphStore] = await Promise.all([
|
|
989
|
+
getP2PStore(deps.stores),
|
|
990
|
+
getGraphStore(deps.stores)
|
|
991
|
+
]);
|
|
992
|
+
// Access gate — runs BEFORE pull and BEFORE share, so a rejected call
|
|
993
|
+
// writes nothing and activates nothing (fail-closed, matching
|
|
994
|
+
// sharePeerGroup's guard). `getCircle` excludes removed rows, so an
|
|
995
|
+
// unknown OR tombstoned circle reads null here.
|
|
996
|
+
const circle = await p2pStore.getCircle(circleID);
|
|
997
|
+
if (circle == null) {
|
|
998
|
+
throw new GraphQLError(`setCircleSync: circle ${circleID} not found or removed`);
|
|
999
|
+
}
|
|
1000
|
+
// Membership per this device's own view (open circles count a group member
|
|
1001
|
+
// as a member); an unreachable circle is rejected before any write.
|
|
1002
|
+
if (!await p2pStore.isMemberOfAnyCircle(selfDID, [
|
|
1003
|
+
circleID
|
|
1004
|
+
])) {
|
|
1005
|
+
throw new GraphQLError(`setCircleSync: ${selfDID} cannot access circle ${circleID} — not a member`);
|
|
1006
|
+
}
|
|
1007
|
+
const groupID = circle.group_id;
|
|
1008
|
+
// Ordering: local pull first, then the share rows, then the push. A crash
|
|
1009
|
+
// after pull, before share, leaves pull active and share unwritten; a retry
|
|
1010
|
+
// re-activates (flag no-op) and re-writes the share (LWW by HLC). Re-applying
|
|
1011
|
+
// the identical end state is a no-op: the diff is empty and the flags match.
|
|
1012
|
+
// 1. pull — purely local catalog activation. No entry, no broadcast, no push.
|
|
1013
|
+
// The circle's catalogs flip together, so one statement covers them.
|
|
1014
|
+
if (pull != null) {
|
|
1015
|
+
await graphStore.setCatalogsActive(circle.catalog_ids, pull);
|
|
1016
|
+
}
|
|
1017
|
+
// 2. share — an END-STATE diff over the caller's OWN read defaults naming
|
|
1018
|
+
// the circle. Every write is owner-signed (owner == caller) through the
|
|
1019
|
+
// executors, so it only ever touches the caller's rows.
|
|
1020
|
+
const pushRows = [];
|
|
1021
|
+
if (share != null) {
|
|
1022
|
+
const ownDefaults = await graphStore.listUserModelAccessDefaults(selfDID);
|
|
1023
|
+
const currentShared = new Set(circleSharing(ownDefaults, circleID));
|
|
1024
|
+
const target = new Set(share.models);
|
|
1025
|
+
// The rules the two arms read are already in `ownDefaults`; index it
|
|
1026
|
+
// rather than re-reading each model. The arms are disjoint by
|
|
1027
|
+
// construction (`target` vs `currentShared \ target`), so neither arm's
|
|
1028
|
+
// writes stale the other's lookups.
|
|
1029
|
+
const existingByKey = indexDefaults(ownDefaults);
|
|
1030
|
+
// Add: a target model whose read rule does not yet name the circle.
|
|
1031
|
+
for (const modelID of target){
|
|
1032
|
+
if (currentShared.has(modelID)) {
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
const existing = existingByKey.get(`${modelID}:read`);
|
|
1036
|
+
if (existing == null) {
|
|
1037
|
+
// No read rule: write a fresh restricted rule naming only the circle.
|
|
1038
|
+
await params.setReadDefault({
|
|
1039
|
+
modelID,
|
|
1040
|
+
accessLevel: 'restricted',
|
|
1041
|
+
allowedDIDs: null,
|
|
1042
|
+
allowedCircles: [
|
|
1043
|
+
circleID
|
|
1044
|
+
],
|
|
1045
|
+
allowedGroups: null
|
|
1046
|
+
});
|
|
1047
|
+
} else {
|
|
1048
|
+
// Union the circle into the existing rule, preserving any
|
|
1049
|
+
// allowedDIDs / allowedGroups / other circles. `only_owner` reads no
|
|
1050
|
+
// circle at all, so leaving the level would name the circle in a rule
|
|
1051
|
+
// that grants it nothing — the share would be reported and mean
|
|
1052
|
+
// nothing. Sharing IS the request to let the circle read, so the
|
|
1053
|
+
// level widens to `restricted`. `anyone` already grants the circle
|
|
1054
|
+
// read and is never narrowed.
|
|
1055
|
+
const nextCircles = Array.from(new Set([
|
|
1056
|
+
...existing.allowedCircles ?? [],
|
|
1057
|
+
circleID
|
|
1058
|
+
]));
|
|
1059
|
+
const accessLevel = existing.level === 'only_owner' ? 'restricted' : existing.level;
|
|
1060
|
+
await params.setReadDefault({
|
|
1061
|
+
modelID,
|
|
1062
|
+
accessLevel,
|
|
1063
|
+
allowedDIDs: existing.allowedDIDs,
|
|
1064
|
+
allowedCircles: nextCircles,
|
|
1065
|
+
allowedGroups: existing.allowedGroups
|
|
1066
|
+
});
|
|
1067
|
+
if (existing.level === 'only_owner') {
|
|
1068
|
+
deps.logger.warn('setCircleSync: model read default was only_owner; widened to restricted so the circle can read', {
|
|
1069
|
+
modelID,
|
|
1070
|
+
circleID
|
|
1071
|
+
});
|
|
1072
|
+
} else if (existing.level === 'anyone') {
|
|
1073
|
+
deps.logger.warn('setCircleSync: model read default already grants anyone; circle added, level not narrowed', {
|
|
1074
|
+
modelID,
|
|
1075
|
+
circleID
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
// Remove: a model that currently names the circle but is not in target.
|
|
1081
|
+
for (const modelID of currentShared){
|
|
1082
|
+
if (target.has(modelID)) {
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
const existing = existingByKey.get(`${modelID}:read`);
|
|
1086
|
+
if (existing == null) {
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
const nextCircles = (existing.allowedCircles ?? []).filter((c)=>c !== circleID);
|
|
1090
|
+
const noDIDs = (existing.allowedDIDs ?? []).length === 0;
|
|
1091
|
+
const noGroups = (existing.allowedGroups ?? []).length === 0;
|
|
1092
|
+
if (existing.level === 'restricted' && nextCircles.length === 0 && noDIDs && noGroups) {
|
|
1093
|
+
// A `restricted` rule with no circles/DIDs/groups grants NO ONE
|
|
1094
|
+
// (owner only). Remove it entirely so the model returns to the SERVER
|
|
1095
|
+
// default rather than being pinned owner-only under a future default
|
|
1096
|
+
// change — the faithful "stop sharing entirely". Fires
|
|
1097
|
+
// `access-default:remove`. The restriction to the `restricted` level
|
|
1098
|
+
// is deliberate: removing an emptied `only_owner` rule would WIDEN to
|
|
1099
|
+
// the server default, and removing an `anyone` rule would silently
|
|
1100
|
+
// narrow a global grant — neither is the caller's intent, so those
|
|
1101
|
+
// are rewritten with the circle dropped instead.
|
|
1102
|
+
await params.removeReadDefault(modelID);
|
|
1103
|
+
} else {
|
|
1104
|
+
await params.setReadDefault({
|
|
1105
|
+
modelID,
|
|
1106
|
+
accessLevel: existing.level,
|
|
1107
|
+
allowedDIDs: existing.allowedDIDs,
|
|
1108
|
+
allowedCircles: nextCircles.length > 0 ? nextCircles : null,
|
|
1109
|
+
allowedGroups: existing.allowedGroups
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
// Removals/tightenings are NOT pushed — they propagate via the
|
|
1113
|
+
// `access-default:remove` / `:set` broadcasts (eventual). A brief
|
|
1114
|
+
// over-serve by a peer is the same exposure as any circle close.
|
|
1115
|
+
}
|
|
1116
|
+
// Push rows are collected from the WHOLE target set, not just the models
|
|
1117
|
+
// this call added: a previous attempt that committed the share rows and
|
|
1118
|
+
// then failed on the push must re-push on retry, and that retry sees the
|
|
1119
|
+
// models as already shared. Re-pushing a row a peer already holds is a
|
|
1120
|
+
// no-op there (LWW by the row's own HLC).
|
|
1121
|
+
// Re-read AFTER the writes above — these rows are their post-state, and
|
|
1122
|
+
// the row carries its own anchor, so one list replaces a rule + HLC pair
|
|
1123
|
+
// per model. Signing is CPU-bound and touches no store, so the tokens are
|
|
1124
|
+
// minted concurrently.
|
|
1125
|
+
const writtenByKey = indexDefaults(await graphStore.listUserModelAccessDefaults(selfDID));
|
|
1126
|
+
const signed = await Promise.all(Array.from(target, async (modelID)=>{
|
|
1127
|
+
const written = writtenByKey.get(`${modelID}:read`);
|
|
1128
|
+
if (written?.hlc == null) {
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|
|
1131
|
+
const hlc = written.hlc;
|
|
1132
|
+
const rule = {
|
|
1133
|
+
level: written.level,
|
|
1134
|
+
allowedDIDs: written.allowedDIDs,
|
|
1135
|
+
allowedCircles: written.allowedCircles,
|
|
1136
|
+
allowedGroups: written.allowedGroups
|
|
1137
|
+
};
|
|
1138
|
+
const auth = await signAccessDefaultSet(deps.identity, {
|
|
1139
|
+
modelID,
|
|
1140
|
+
permissionType: 'read',
|
|
1141
|
+
rule,
|
|
1142
|
+
hlc
|
|
1143
|
+
});
|
|
1144
|
+
return {
|
|
1145
|
+
ownerDID: selfDID,
|
|
1146
|
+
modelID,
|
|
1147
|
+
permissionType: 'read',
|
|
1148
|
+
rule,
|
|
1149
|
+
hlc,
|
|
1150
|
+
auth
|
|
1151
|
+
};
|
|
1152
|
+
}));
|
|
1153
|
+
pushRows.push(...signed.filter((row)=>row != null));
|
|
1154
|
+
}
|
|
1155
|
+
// 3. Push the shared models' read-default SET rows to the group's connected
|
|
1156
|
+
// in-group peers so
|
|
1157
|
+
// a counterparty can authorize the new pull without waiting on hub fan-out
|
|
1158
|
+
// (which may be dropped for a group with no live binding). Push failure
|
|
1159
|
+
// fails the mutation (unwrapped, per the end-state contract); the retry is
|
|
1160
|
+
// idempotent. `servePushControl` has NO remove arm — a "stop sharing"
|
|
1161
|
+
// tightening is broadcast-only (known gap), never pushed here.
|
|
1162
|
+
// Distinct peers over the network with nothing shared between them, so the
|
|
1163
|
+
// pushes run concurrently: sequentially, one slow peer delays every peer
|
|
1164
|
+
// behind it. A failure still fails the mutation, but every peer is
|
|
1165
|
+
// attempted first rather than the fan-out stopping at the first error —
|
|
1166
|
+
// strictly closer to the end-state contract, since the retry is idempotent.
|
|
1167
|
+
if (pushRows.length > 0) {
|
|
1168
|
+
const connections = deps.peerConnections.list();
|
|
1169
|
+
const inGroup = await Promise.all(connections.map((connection)=>p2pStore.isGroupMember(groupID, connection.peerDID)));
|
|
1170
|
+
await Promise.all(connections.filter((_connection, index)=>inGroup[index]).map((connection)=>connection.client.request('group/push-control', {
|
|
1171
|
+
param: {
|
|
1172
|
+
groupID,
|
|
1173
|
+
accessDefaults: pushRows
|
|
1174
|
+
}
|
|
1175
|
+
})));
|
|
1176
|
+
}
|
|
1177
|
+
// Post-state through the shared builder, so what this mutation returns is
|
|
1178
|
+
// exactly what a `circleSync` re-read (or the join-time picker) reports.
|
|
1179
|
+
// The circle row is re-read: a concurrent settings apply may have flipped
|
|
1180
|
+
// `open` while the share was being written.
|
|
1181
|
+
const [reread, finalDefaults] = await Promise.all([
|
|
1182
|
+
p2pStore.getCircle(circleID),
|
|
1183
|
+
graphStore.listUserModelAccessDefaults(selfDID)
|
|
1184
|
+
]);
|
|
1185
|
+
const finalCircle = reread ?? circle;
|
|
1186
|
+
const catalogs = createCatalogLoader(graphStore);
|
|
1187
|
+
await catalogs.prime(finalCircle.catalog_ids);
|
|
1188
|
+
return await circleSyncState(finalCircle, finalDefaults, catalogs.load);
|
|
1189
|
+
},
|
|
1190
|
+
listCircleSyncStates: async (groupID)=>{
|
|
1191
|
+
const selfDID = ctx.viewerDID;
|
|
1192
|
+
const [p2pStore, graphStore] = await Promise.all([
|
|
1193
|
+
getP2PStore(deps.stores),
|
|
1194
|
+
getGraphStore(deps.stores)
|
|
1195
|
+
]);
|
|
1196
|
+
// Only the circles this device can currently reach: an open circle it is a
|
|
1197
|
+
// group member of, or one it holds an explicit grant for. A circle the admin
|
|
1198
|
+
// closed since the join drops out here, which is how the app learns to stop
|
|
1199
|
+
// offering it.
|
|
1200
|
+
const [circles, reachable, ownDefaults] = await Promise.all([
|
|
1201
|
+
p2pStore.listCirclesByGroup(groupID),
|
|
1202
|
+
p2pStore.getCirclesForMember(selfDID),
|
|
1203
|
+
graphStore.listUserModelAccessDefaults(selfDID)
|
|
1204
|
+
]);
|
|
1205
|
+
const reachableIDs = new Set(reachable.map((circle)=>circle.id));
|
|
1206
|
+
const visible = circles.filter((circle)=>reachableIDs.has(circle.id));
|
|
1207
|
+
const catalogs = createCatalogLoader(graphStore);
|
|
1208
|
+
await catalogs.prime(visible.flatMap((circle)=>circle.catalog_ids));
|
|
1209
|
+
const states = [];
|
|
1210
|
+
for (const circle of visible){
|
|
1211
|
+
states.push(await circleSyncState(circle, ownDefaults, catalogs.load));
|
|
1212
|
+
}
|
|
1213
|
+
return states;
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
}
|