@kubun/plugin-p2p 0.11.0 → 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/group.js +1073 -746
- package/lib/context/hub.js +2 -1
- package/lib/context/join.js +50 -15
- 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 +234 -1
- package/lib/context/types.d.ts +56 -20
- package/lib/context/types.js +23 -3
- 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 +42 -117
- package/lib/groups/admin-roster.js +53 -168
- 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.d.ts +199 -81
- package/lib/groups/broadcast.js +443 -368
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +32 -64
- package/lib/groups/circle-projection.js +95 -181
- package/lib/groups/circle-reducers.d.ts +71 -110
- package/lib/groups/circle-reducers.js +57 -127
- 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 +14 -1
- package/lib/groups/group-anchor.d.ts +18 -57
- package/lib/groups/group-anchor.js +15 -100
- package/lib/groups/group-crypto.d.ts +36 -13
- package/lib/groups/group-crypto.js +124 -50
- package/lib/groups/group-handle-registry.d.ts +106 -36
- package/lib/groups/group-handle-registry.js +351 -61
- package/lib/groups/group-handlers.d.ts +5 -4
- package/lib/groups/group-handlers.js +155 -29
- package/lib/groups/group-health-monitor.d.ts +1 -1
- package/lib/groups/group-health-monitor.js +1 -1
- package/lib/groups/group-mls.d.ts +35 -4
- package/lib/groups/group-mls.js +594 -62
- package/lib/groups/group-peer-manager.d.ts +212 -19
- package/lib/groups/group-peer-manager.js +1061 -69
- package/lib/groups/group-protocols.d.ts +485 -28
- package/lib/groups/group-protocols.js +289 -15
- package/lib/groups/invite-payload.d.ts +48 -0
- 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 +60 -0
- package/lib/groups/join-utils.js +262 -0
- package/lib/groups/ledger-affected-events.d.ts +11 -6
- package/lib/groups/ledger-affected-events.js +15 -26
- 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 +202 -19
- package/lib/groups/manager.js +623 -106
- package/lib/groups/mls-codec.d.ts +27 -6
- package/lib/groups/mls-codec.js +30 -15
- package/lib/groups/mls-encryptor.js +9 -6
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +12 -4
- package/lib/groups/mls-receive-errors.d.ts +0 -2
- package/lib/groups/mls-receive-errors.js +3 -6
- package/lib/groups/mls-state.d.ts +5 -3
- package/lib/groups/mls-state.js +27 -4
- 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 +4 -3
- package/lib/groups/store-received-grant.js +80 -35
- package/lib/groups/store-received-revocation.d.ts +16 -13
- package/lib/groups/store-received-revocation.js +23 -22
- package/lib/hub/http-client.d.ts +10 -27
- package/lib/hub/http-client.js +2 -19
- package/lib/hub/hub-like.d.ts +57 -6
- package/lib/hub/hub-like.js +539 -52
- 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 +51 -94
- 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 +71 -14
- package/lib/hub/wiring.js +80 -23
- package/lib/index.d.ts +48 -13
- package/lib/index.js +257 -86
- 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 +213 -0
- package/lib/protocol.js +268 -0
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +805 -131
- 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 +57 -3
- package/lib/sync/authorize.js +45 -4
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +2 -3
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +55 -39
- package/lib/sync/errors.d.ts +9 -0
- package/lib/sync/errors.js +10 -0
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +13 -19
- package/lib/sync/handlers.js +54 -14
- package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
- package/lib/sync/hub-tunnel-sync-listener.js +126 -37
- package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
- package/lib/sync/hub-tunnel-sync-provider.js +68 -19
- package/lib/sync/merkle-channel.d.ts +9 -1
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/peer-registry.d.ts +14 -10
- package/lib/sync/peer-registry.js +0 -25
- package/lib/sync/sync-client.d.ts +11 -1
- package/lib/sync/sync-client.js +72 -10
- package/lib/sync/sync-manager.d.ts +22 -4
- package/lib/sync/sync-manager.js +39 -10
- 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 +699 -28
- package/lib/types.js +0 -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 +37 -3
- package/package.json +56 -52
- package/lib/groups/ledger-entry.d.ts +0 -37
- package/lib/groups/ledger-entry.js +0 -56
- package/lib/groups/ledger-fold.d.ts +0 -56
- package/lib/groups/ledger-fold.js +0 -53
- package/lib/hub/did-observing-transport.d.ts +0 -75
- package/lib/hub/did-observing-transport.js +0 -126
- package/lib/hub/errors.d.ts +0 -24
- package/lib/hub/errors.js +0 -33
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HubStore } from '@kumiai/hub-protocol';
|
|
2
|
+
import type { LogHub } from '@kumiai/hub-tunnel';
|
|
3
|
+
/**
|
|
4
|
+
* An in-process `LogHub` over an in-memory `HubStore` that DELIVERS: a frame
|
|
5
|
+
* published on a topic is pushed to every OTHER subscriber's `receive` drain, so
|
|
6
|
+
* a peer bound to this hub wakes on a co-member's publish and pulls the log. The
|
|
7
|
+
* store owns the log semantics (append, compare-and-set on the head,
|
|
8
|
+
* `fetchTopic`); this wrapper owns the per-subscriber push fan-out the store has
|
|
9
|
+
* no notion of.
|
|
10
|
+
*
|
|
11
|
+
* The sender is excluded from its own fan-out (a publisher has already adopted
|
|
12
|
+
* what it published), matching the store's own recipient rule.
|
|
13
|
+
*
|
|
14
|
+
* The log is EPHEMERAL. It backs a group with no hub bound, where the device is
|
|
15
|
+
* the only member and so the only writer: the lane needs something to
|
|
16
|
+
* compare-and-set a head against, not a durable record. The MLS handle and the
|
|
17
|
+
* `CommitJournal` survive a restart, persisted elsewhere; these frames are never
|
|
18
|
+
* replayed, migrated, or served to another member.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createLoopbackLogHub(store?: HubStore): LogHub;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createMemoryStore } from '@kumiai/hub-server';
|
|
2
|
+
/**
|
|
3
|
+
* An in-process `LogHub` over an in-memory `HubStore` that DELIVERS: a frame
|
|
4
|
+
* published on a topic is pushed to every OTHER subscriber's `receive` drain, so
|
|
5
|
+
* a peer bound to this hub wakes on a co-member's publish and pulls the log. The
|
|
6
|
+
* store owns the log semantics (append, compare-and-set on the head,
|
|
7
|
+
* `fetchTopic`); this wrapper owns the per-subscriber push fan-out the store has
|
|
8
|
+
* no notion of.
|
|
9
|
+
*
|
|
10
|
+
* The sender is excluded from its own fan-out (a publisher has already adopted
|
|
11
|
+
* what it published), matching the store's own recipient rule.
|
|
12
|
+
*
|
|
13
|
+
* The log is EPHEMERAL. It backs a group with no hub bound, where the device is
|
|
14
|
+
* the only member and so the only writer: the lane needs something to
|
|
15
|
+
* compare-and-set a head against, not a durable record. The MLS handle and the
|
|
16
|
+
* `CommitJournal` survive a restart, persisted elsewhere; these frames are never
|
|
17
|
+
* replayed, migrated, or served to another member.
|
|
18
|
+
*/ export function createLoopbackLogHub(store = createMemoryStore()) {
|
|
19
|
+
// topicID -> the DIDs subscribed to it.
|
|
20
|
+
const topicSubscribers = new Map();
|
|
21
|
+
// subscriberDID -> its live receive sinks (one per open `receive` drain).
|
|
22
|
+
const sinks = new Map();
|
|
23
|
+
const deliver = (message)=>{
|
|
24
|
+
const subscribers = topicSubscribers.get(message.topicID);
|
|
25
|
+
if (subscribers == null) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const subscriberDID of subscribers){
|
|
29
|
+
if (subscriberDID === message.senderDID) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
for (const push of sinks.get(subscriberDID) ?? []){
|
|
33
|
+
push(message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
async subscribe (subscriberDID, topicID, options) {
|
|
39
|
+
await store.subscribe({
|
|
40
|
+
subscriberDID,
|
|
41
|
+
topicID,
|
|
42
|
+
...options?.retention != null ? {
|
|
43
|
+
retention: options.retention
|
|
44
|
+
} : {}
|
|
45
|
+
});
|
|
46
|
+
let set = topicSubscribers.get(topicID);
|
|
47
|
+
if (set == null) {
|
|
48
|
+
set = new Set();
|
|
49
|
+
topicSubscribers.set(topicID, set);
|
|
50
|
+
}
|
|
51
|
+
set.add(subscriberDID);
|
|
52
|
+
},
|
|
53
|
+
async unsubscribe (subscriberDID, topicID) {
|
|
54
|
+
await store.unsubscribe(subscriberDID, topicID);
|
|
55
|
+
topicSubscribers.get(topicID)?.delete(subscriberDID);
|
|
56
|
+
},
|
|
57
|
+
receive (subscriberDID) {
|
|
58
|
+
const queue = [];
|
|
59
|
+
let resolveNext;
|
|
60
|
+
let closed = false;
|
|
61
|
+
const push = (message)=>{
|
|
62
|
+
if (closed) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (resolveNext != null) {
|
|
66
|
+
const resolve = resolveNext;
|
|
67
|
+
resolveNext = undefined;
|
|
68
|
+
resolve({
|
|
69
|
+
value: message,
|
|
70
|
+
done: false
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
queue.push(message);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
let set = sinks.get(subscriberDID);
|
|
77
|
+
if (set == null) {
|
|
78
|
+
set = new Set();
|
|
79
|
+
sinks.set(subscriberDID, set);
|
|
80
|
+
}
|
|
81
|
+
set.add(push);
|
|
82
|
+
const remove = ()=>{
|
|
83
|
+
closed = true;
|
|
84
|
+
sinks.get(subscriberDID)?.delete(push);
|
|
85
|
+
if (resolveNext != null) {
|
|
86
|
+
const resolve = resolveNext;
|
|
87
|
+
resolveNext = undefined;
|
|
88
|
+
resolve({
|
|
89
|
+
value: undefined,
|
|
90
|
+
done: true
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const iterator = {
|
|
95
|
+
next () {
|
|
96
|
+
if (queue.length > 0) {
|
|
97
|
+
return Promise.resolve({
|
|
98
|
+
value: queue.shift(),
|
|
99
|
+
done: false
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (closed) {
|
|
103
|
+
return Promise.resolve({
|
|
104
|
+
value: undefined,
|
|
105
|
+
done: true
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return new Promise((resolve)=>{
|
|
109
|
+
resolveNext = resolve;
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
return () {
|
|
113
|
+
remove();
|
|
114
|
+
return Promise.resolve({
|
|
115
|
+
value: undefined,
|
|
116
|
+
done: true
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
[Symbol.asyncIterator] () {
|
|
122
|
+
return iterator;
|
|
123
|
+
},
|
|
124
|
+
return () {
|
|
125
|
+
remove();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
async publish (params) {
|
|
130
|
+
const result = await store.publish({
|
|
131
|
+
senderDID: params.senderDID,
|
|
132
|
+
topicID: params.topicID,
|
|
133
|
+
payload: params.payload,
|
|
134
|
+
...params.retain != null ? {
|
|
135
|
+
retain: params.retain
|
|
136
|
+
} : {},
|
|
137
|
+
...'expectedHead' in params ? {
|
|
138
|
+
expectedHead: params.expectedHead
|
|
139
|
+
} : {},
|
|
140
|
+
...params.publishID != null ? {
|
|
141
|
+
publishID: params.publishID
|
|
142
|
+
} : {}
|
|
143
|
+
});
|
|
144
|
+
// A deduped replay stored nothing and every current subscriber already saw
|
|
145
|
+
// the original — re-delivering it would apply the same commit twice.
|
|
146
|
+
if (!result.deduped) {
|
|
147
|
+
deliver({
|
|
148
|
+
sequenceID: result.sequenceID,
|
|
149
|
+
senderDID: params.senderDID,
|
|
150
|
+
topicID: params.topicID,
|
|
151
|
+
payload: params.payload,
|
|
152
|
+
// One sequence serves both classes here, so a log frame's own sequenceID is
|
|
153
|
+
// its position in the topic's log — the same value `fetchTopic` serves it at.
|
|
154
|
+
// A mailbox frame carries no key: it has no place in a log, and a falsy
|
|
155
|
+
// placeholder is a position a cursor moves to, skipping every frame below it.
|
|
156
|
+
...params.retain === 'log' ? {
|
|
157
|
+
logPosition: result.sequenceID
|
|
158
|
+
} : {}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
sequenceID: result.sequenceID
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
async fetchTopic (params) {
|
|
166
|
+
return await store.fetchTopic({
|
|
167
|
+
subscriberDID: params.subscriberDID,
|
|
168
|
+
topicID: params.topicID,
|
|
169
|
+
...params.after != null ? {
|
|
170
|
+
after: params.after
|
|
171
|
+
} : {},
|
|
172
|
+
...params.limit != null ? {
|
|
173
|
+
limit: params.limit
|
|
174
|
+
} : {}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
package/lib/hub/manager.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StoreProvider } from '@kubun/db';
|
|
2
|
+
import type { Runtime } from '@sozai/runtime';
|
|
2
3
|
export type Hub = {
|
|
3
4
|
id: string;
|
|
4
5
|
label: string | null;
|
|
@@ -12,6 +13,12 @@ export type CreateHubParams = {
|
|
|
12
13
|
url: string;
|
|
13
14
|
label?: string | null;
|
|
14
15
|
serverDID?: string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Host runtime, used here to mint the new hub row's id. Propagated from the
|
|
18
|
+
* caller rather than constructed: this package runs on React Native and in
|
|
19
|
+
* the browser as well as on node, where node's `crypto` is not available.
|
|
20
|
+
*/
|
|
21
|
+
runtime: Runtime;
|
|
15
22
|
};
|
|
16
23
|
export type UpdateHubParams = {
|
|
17
24
|
stores: StoreProvider;
|
|
@@ -54,46 +61,36 @@ export type ListGroupsByHubParams = {
|
|
|
54
61
|
stores: StoreProvider;
|
|
55
62
|
hubID: string;
|
|
56
63
|
};
|
|
57
|
-
export type CaptureServerDIDParams = {
|
|
58
|
-
stores: StoreProvider;
|
|
59
|
-
hubURL: string;
|
|
60
|
-
serverDID: string;
|
|
61
|
-
};
|
|
62
64
|
export type UpsertHubParams = {
|
|
63
65
|
stores: StoreProvider;
|
|
64
66
|
url: string;
|
|
65
67
|
serverDID?: string | null;
|
|
68
|
+
/** As `CreateHubParams.runtime`. */
|
|
69
|
+
runtime: Runtime;
|
|
66
70
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
get url(): string;
|
|
77
|
-
get existingServerDID(): string;
|
|
78
|
-
get providedServerDID(): string;
|
|
79
|
-
}
|
|
71
|
+
/**
|
|
72
|
+
* Register a hub by URL, or return the row that URL already has.
|
|
73
|
+
*
|
|
74
|
+
* `serverDID` is a cache of the hub's current identity, not a pin, so a provided
|
|
75
|
+
* DID that differs from the recorded one simply replaces it — a hub redeployed
|
|
76
|
+
* under a new key is a routine event, and a wrong value here costs one refused
|
|
77
|
+
* connection, after which the device rediscovers the DID from the hub's `/info`.
|
|
78
|
+
* A provided null never clears a recorded DID.
|
|
79
|
+
*/
|
|
80
80
|
export declare function createHub(params: CreateHubParams): Promise<Hub>;
|
|
81
81
|
/**
|
|
82
|
-
* Idempotent register-or-
|
|
82
|
+
* Idempotent register-or-record entry-point. Applies the URL matrix:
|
|
83
83
|
*
|
|
84
84
|
* - URL not registered → insert a fresh row with `serverDID` (or null) and
|
|
85
85
|
* return it.
|
|
86
|
-
* - existing row,
|
|
87
|
-
* - existing row,
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* - existing row, `server_did === DID-A`, provided DID-B → throws
|
|
92
|
-
* `HubServerDIDConflictError`. Caller must explicit `updateHub` to change
|
|
93
|
-
* a pinned DID.
|
|
86
|
+
* - existing row, provided null → no-op, whatever the row records.
|
|
87
|
+
* - existing row, provided DID-A matching the record → no-op.
|
|
88
|
+
* - existing row, provided DID-A differing from the record → record DID-A. The
|
|
89
|
+
* recorded DID is a cache of the hub's current identity, so the newest claim
|
|
90
|
+
* wins and a wrong one self-corrects from `/info` on the next connect.
|
|
94
91
|
*
|
|
95
92
|
* Unlike `createHub`, `upsertHub` takes no `label` — it is for atomic
|
|
96
|
-
* register+bind paths where the inviter only ships connectivity +
|
|
93
|
+
* register+bind paths where the inviter only ships connectivity + hub identity.
|
|
97
94
|
*/
|
|
98
95
|
export declare function upsertHub(params: UpsertHubParams): Promise<Hub>;
|
|
99
96
|
export declare function updateHub(params: UpdateHubParams): Promise<Hub>;
|
|
@@ -104,15 +101,3 @@ export declare function bindHubToGroup(params: BindHubToGroupParams): Promise<bo
|
|
|
104
101
|
export declare function unbindHubFromGroup(params: UnbindHubFromGroupParams): Promise<boolean>;
|
|
105
102
|
export declare function listHubsByGroup(params: ListHubsByGroupParams): Promise<Array<Hub>>;
|
|
106
103
|
export declare function listGroupsByHub(params: ListGroupsByHubParams): Promise<Array<string>>;
|
|
107
|
-
/**
|
|
108
|
-
* TOFU capture path. Looks up the local hub row by URL and applies the
|
|
109
|
-
* conflict matrix:
|
|
110
|
-
* - URL not registered → no-op, returns `false`.
|
|
111
|
-
* - existing `server_did` is null → upgrade to `serverDID`, returns `true`.
|
|
112
|
-
* - existing `server_did` matches `serverDID` → no-op, returns `false`.
|
|
113
|
-
* - existing `server_did` differs from `serverDID` → throws
|
|
114
|
-
* `HubServerDIDConflictError`. Pinned-mismatch enforcement (hard-fail
|
|
115
|
-
* before any message is delivered) lives in the transport wrapper and
|
|
116
|
-
* surfaces `HubServerDIDMismatchError` to callers.
|
|
117
|
-
*/
|
|
118
|
-
export declare function captureServerDID(params: CaptureServerDIDParams): Promise<boolean>;
|
package/lib/hub/manager.js
CHANGED
|
@@ -1,30 +1,4 @@
|
|
|
1
1
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
2
|
-
export class HubServerDIDConflictError extends Error {
|
|
3
|
-
#hubID;
|
|
4
|
-
#url;
|
|
5
|
-
#existingServerDID;
|
|
6
|
-
#providedServerDID;
|
|
7
|
-
constructor(params){
|
|
8
|
-
super(`hub at ${params.url} is pinned to ${params.existingServerDID}; refusing to overwrite with ${params.providedServerDID}`);
|
|
9
|
-
this.name = 'HubServerDIDConflictError';
|
|
10
|
-
this.#hubID = params.hubID;
|
|
11
|
-
this.#url = params.url;
|
|
12
|
-
this.#existingServerDID = params.existingServerDID;
|
|
13
|
-
this.#providedServerDID = params.providedServerDID;
|
|
14
|
-
}
|
|
15
|
-
get hubID() {
|
|
16
|
-
return this.#hubID;
|
|
17
|
-
}
|
|
18
|
-
get url() {
|
|
19
|
-
return this.#url;
|
|
20
|
-
}
|
|
21
|
-
get existingServerDID() {
|
|
22
|
-
return this.#existingServerDID;
|
|
23
|
-
}
|
|
24
|
-
get providedServerDID() {
|
|
25
|
-
return this.#providedServerDID;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
2
|
function rowToHub(row) {
|
|
29
3
|
return {
|
|
30
4
|
id: row.id,
|
|
@@ -35,61 +9,76 @@ function rowToHub(row) {
|
|
|
35
9
|
updatedAt: row.updated_at == null ? null : new Date(row.updated_at)
|
|
36
10
|
};
|
|
37
11
|
}
|
|
38
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Return an existing hub row, having replaced its recorded server DID when the
|
|
14
|
+
* caller supplied a different one. A provided null never clears what is there.
|
|
15
|
+
*/ async function adoptServerDID(store, row, providedDID) {
|
|
16
|
+
if (providedDID == null || row.server_did === providedDID) {
|
|
17
|
+
return rowToHub(row);
|
|
18
|
+
}
|
|
19
|
+
await store.updateHub(row.id, {
|
|
20
|
+
server_did: providedDID
|
|
21
|
+
});
|
|
22
|
+
const updated = await store.getHub(row.id);
|
|
23
|
+
if (updated == null) throw new Error('Failed to read hub after adopting a server DID');
|
|
24
|
+
return rowToHub(updated);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Register a hub by URL, or return the row that URL already has.
|
|
28
|
+
*
|
|
29
|
+
* `serverDID` is a cache of the hub's current identity, not a pin, so a provided
|
|
30
|
+
* DID that differs from the recorded one simply replaces it — a hub redeployed
|
|
31
|
+
* under a new key is a routine event, and a wrong value here costs one refused
|
|
32
|
+
* connection, after which the device rediscovers the DID from the hub's `/info`.
|
|
33
|
+
* A provided null never clears a recorded DID.
|
|
34
|
+
*/ export async function createHub(params) {
|
|
39
35
|
const store = await getP2PStore(params.stores);
|
|
40
36
|
const providedDID = params.serverDID ?? null;
|
|
41
37
|
const existing = await store.getHubByURL(params.url);
|
|
42
38
|
if (existing != null) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
39
|
+
return await adoptServerDID(store, existing, providedDID);
|
|
40
|
+
}
|
|
41
|
+
const id = params.runtime.getRandomID();
|
|
42
|
+
try {
|
|
43
|
+
await store.insertHub({
|
|
44
|
+
id,
|
|
45
|
+
label: params.label ?? null,
|
|
46
|
+
url: params.url,
|
|
47
|
+
server_did: providedDID
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// The check above and this insert are two statements against a UNIQUE url,
|
|
51
|
+
// so a concurrent register of the same hub — two groups binding it at once,
|
|
52
|
+
// an invite's `suggestedHubs` racing a manual add — makes the loser throw out
|
|
53
|
+
// of a function whose whole contract is idempotence. Adopt the winner's row
|
|
54
|
+
// instead; a failure that is not the race has no row to find and rethrows.
|
|
55
|
+
const raced = await store.getHubByURL(params.url);
|
|
56
|
+
if (raced == null) throw error;
|
|
57
|
+
return await adoptServerDID(store, raced, providedDID);
|
|
60
58
|
}
|
|
61
|
-
const id = crypto.randomUUID();
|
|
62
|
-
await store.insertHub({
|
|
63
|
-
id,
|
|
64
|
-
label: params.label ?? null,
|
|
65
|
-
url: params.url,
|
|
66
|
-
server_did: providedDID
|
|
67
|
-
});
|
|
68
59
|
const created = await store.getHub(id);
|
|
69
60
|
if (created == null) throw new Error('Failed to read hub after insert');
|
|
70
61
|
return rowToHub(created);
|
|
71
62
|
}
|
|
72
63
|
/**
|
|
73
|
-
* Idempotent register-or-
|
|
64
|
+
* Idempotent register-or-record entry-point. Applies the URL matrix:
|
|
74
65
|
*
|
|
75
66
|
* - URL not registered → insert a fresh row with `serverDID` (or null) and
|
|
76
67
|
* return it.
|
|
77
|
-
* - existing row,
|
|
78
|
-
* - existing row,
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* - existing row, `server_did === DID-A`, provided DID-B → throws
|
|
83
|
-
* `HubServerDIDConflictError`. Caller must explicit `updateHub` to change
|
|
84
|
-
* a pinned DID.
|
|
68
|
+
* - existing row, provided null → no-op, whatever the row records.
|
|
69
|
+
* - existing row, provided DID-A matching the record → no-op.
|
|
70
|
+
* - existing row, provided DID-A differing from the record → record DID-A. The
|
|
71
|
+
* recorded DID is a cache of the hub's current identity, so the newest claim
|
|
72
|
+
* wins and a wrong one self-corrects from `/info` on the next connect.
|
|
85
73
|
*
|
|
86
74
|
* Unlike `createHub`, `upsertHub` takes no `label` — it is for atomic
|
|
87
|
-
* register+bind paths where the inviter only ships connectivity +
|
|
75
|
+
* register+bind paths where the inviter only ships connectivity + hub identity.
|
|
88
76
|
*/ export async function upsertHub(params) {
|
|
89
77
|
return await createHub({
|
|
90
78
|
stores: params.stores,
|
|
91
79
|
url: params.url,
|
|
92
|
-
serverDID: params.serverDID
|
|
80
|
+
serverDID: params.serverDID,
|
|
81
|
+
runtime: params.runtime
|
|
93
82
|
});
|
|
94
83
|
}
|
|
95
84
|
export async function updateHub(params) {
|
|
@@ -164,35 +153,3 @@ export async function listGroupsByHub(params) {
|
|
|
164
153
|
const store = await getP2PStore(params.stores);
|
|
165
154
|
return await store.listGroupIDsByHubID(params.hubID);
|
|
166
155
|
}
|
|
167
|
-
/**
|
|
168
|
-
* TOFU capture path. Looks up the local hub row by URL and applies the
|
|
169
|
-
* conflict matrix:
|
|
170
|
-
* - URL not registered → no-op, returns `false`.
|
|
171
|
-
* - existing `server_did` is null → upgrade to `serverDID`, returns `true`.
|
|
172
|
-
* - existing `server_did` matches `serverDID` → no-op, returns `false`.
|
|
173
|
-
* - existing `server_did` differs from `serverDID` → throws
|
|
174
|
-
* `HubServerDIDConflictError`. Pinned-mismatch enforcement (hard-fail
|
|
175
|
-
* before any message is delivered) lives in the transport wrapper and
|
|
176
|
-
* surfaces `HubServerDIDMismatchError` to callers.
|
|
177
|
-
*/ export async function captureServerDID(params) {
|
|
178
|
-
const store = await getP2PStore(params.stores);
|
|
179
|
-
const existing = await store.getHubByURL(params.hubURL);
|
|
180
|
-
if (existing == null) {
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
if (existing.server_did == null) {
|
|
184
|
-
await store.updateHub(existing.id, {
|
|
185
|
-
server_did: params.serverDID
|
|
186
|
-
});
|
|
187
|
-
return true;
|
|
188
|
-
}
|
|
189
|
-
if (existing.server_did === params.serverDID) {
|
|
190
|
-
return false;
|
|
191
|
-
}
|
|
192
|
-
throw new HubServerDIDConflictError({
|
|
193
|
-
hubID: existing.id,
|
|
194
|
-
url: params.hubURL,
|
|
195
|
-
existingServerDID: existing.server_did,
|
|
196
|
-
providedServerDID: params.serverDID
|
|
197
|
-
});
|
|
198
|
-
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { MailboxHub } from '@kumiai/hub-tunnel';
|
|
2
|
+
export type PeerScopedHubViewParams = {
|
|
3
|
+
/** The device-wide hub (from `createHubLike`) whose drain is shared. */
|
|
4
|
+
hub: MailboxHub;
|
|
5
|
+
/** Only frames the hub authenticated as sent by this DID are surfaced. */
|
|
6
|
+
peerDID: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* A view over one device's hub that surfaces only the frames one peer sent.
|
|
10
|
+
*
|
|
11
|
+
* Every tunnel responder on a device receives on the same topic — this device's
|
|
12
|
+
* responder topic for the epoch, shared by its listeners for every co-member —
|
|
13
|
+
* so the shared drain hands each of them every peer's frames. Two hazards
|
|
14
|
+
* follow.
|
|
15
|
+
*
|
|
16
|
+
* **The filter has to run before the frames are decrypted.** MLS consumes a
|
|
17
|
+
* per-sender ratchet generation on open, so a responder that opens a frame
|
|
18
|
+
* addressed to a different peer has DESTROYED it: the responder it belonged to
|
|
19
|
+
* can never open it, and that peer's request vanishes with no error raised
|
|
20
|
+
* anywhere. Filtering already-decrypted frames leaves the defect untouched.
|
|
21
|
+
* Build this view over the device hub and hand the VIEW to the encrypting
|
|
22
|
+
* transport — never the other way around.
|
|
23
|
+
*
|
|
24
|
+
* `senderDID` is the hub's attestation, taken from the signed publisher, not an
|
|
25
|
+
* MLS-authenticated identity. That is enough: the filter routes, MLS still
|
|
26
|
+
* authorizes, and a hostile hub could only hide frames it can already drop.
|
|
27
|
+
*
|
|
28
|
+
* **`unsubscribe` is dropped.** The responder topic belongs to the (group,
|
|
29
|
+
* epoch), not to a session: every listener on the device shares it, one per
|
|
30
|
+
* co-member, and each respawns independently — so a departing transport must not
|
|
31
|
+
* take it away from them. Releasing it belongs to whoever owns the group's peer.
|
|
32
|
+
* `subscribe` is
|
|
33
|
+
* therefore issued once per topic and held for the life of the view.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createPeerScopedHubView({ hub, peerDID }: PeerScopedHubViewParams): MailboxHub;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A view over one device's hub that surfaces only the frames one peer sent.
|
|
3
|
+
*
|
|
4
|
+
* Every tunnel responder on a device receives on the same topic — this device's
|
|
5
|
+
* responder topic for the epoch, shared by its listeners for every co-member —
|
|
6
|
+
* so the shared drain hands each of them every peer's frames. Two hazards
|
|
7
|
+
* follow.
|
|
8
|
+
*
|
|
9
|
+
* **The filter has to run before the frames are decrypted.** MLS consumes a
|
|
10
|
+
* per-sender ratchet generation on open, so a responder that opens a frame
|
|
11
|
+
* addressed to a different peer has DESTROYED it: the responder it belonged to
|
|
12
|
+
* can never open it, and that peer's request vanishes with no error raised
|
|
13
|
+
* anywhere. Filtering already-decrypted frames leaves the defect untouched.
|
|
14
|
+
* Build this view over the device hub and hand the VIEW to the encrypting
|
|
15
|
+
* transport — never the other way around.
|
|
16
|
+
*
|
|
17
|
+
* `senderDID` is the hub's attestation, taken from the signed publisher, not an
|
|
18
|
+
* MLS-authenticated identity. That is enough: the filter routes, MLS still
|
|
19
|
+
* authorizes, and a hostile hub could only hide frames it can already drop.
|
|
20
|
+
*
|
|
21
|
+
* **`unsubscribe` is dropped.** The responder topic belongs to the (group,
|
|
22
|
+
* epoch), not to a session: every listener on the device shares it, one per
|
|
23
|
+
* co-member, and each respawns independently — so a departing transport must not
|
|
24
|
+
* take it away from them. Releasing it belongs to whoever owns the group's peer.
|
|
25
|
+
* `subscribe` is
|
|
26
|
+
* therefore issued once per topic and held for the life of the view.
|
|
27
|
+
*/ export function createPeerScopedHubView({ hub, peerDID }) {
|
|
28
|
+
const armed = new Set();
|
|
29
|
+
const view = {
|
|
30
|
+
publish: (params)=>hub.publish(params),
|
|
31
|
+
subscribe: async (subscriberDID, topicID, options)=>{
|
|
32
|
+
if (armed.has(topicID)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
armed.add(topicID);
|
|
36
|
+
try {
|
|
37
|
+
await hub.subscribe(subscriberDID, topicID, options);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
armed.delete(topicID);
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
unsubscribe: ()=>{},
|
|
44
|
+
receive: (subscriberDID, options)=>{
|
|
45
|
+
// Forwarded, not dropped: the transport asks for its own `receiveTopicID`,
|
|
46
|
+
// and a responder handed the frames of the DIALING role on this same peer
|
|
47
|
+
// opens them — consuming the ratchet generation the dialer's own session
|
|
48
|
+
// was waiting on. Sender scope alone cannot tell those two apart.
|
|
49
|
+
const inner = hub.receive(subscriberDID, options);
|
|
50
|
+
const innerIterator = inner[Symbol.asyncIterator]();
|
|
51
|
+
const iterator = {
|
|
52
|
+
next: async ()=>{
|
|
53
|
+
while(true){
|
|
54
|
+
const result = await innerIterator.next();
|
|
55
|
+
if (result.done === true) {
|
|
56
|
+
return {
|
|
57
|
+
value: undefined,
|
|
58
|
+
done: true
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (result.value.senderDID === peerDID) {
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
return: ()=>{
|
|
67
|
+
void innerIterator.return?.();
|
|
68
|
+
return Promise.resolve({
|
|
69
|
+
value: undefined,
|
|
70
|
+
done: true
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
[Symbol.asyncIterator]: ()=>iterator,
|
|
76
|
+
return: ()=>{
|
|
77
|
+
inner.return?.();
|
|
78
|
+
},
|
|
79
|
+
// Forwarded, never synthesized: a frame this view filtered out belongs to
|
|
80
|
+
// another peer's responder, and acking it here would tell the hub it was
|
|
81
|
+
// handled by a subscriber that never saw it.
|
|
82
|
+
...inner.ack != null ? {
|
|
83
|
+
ack: (sequenceID)=>inner.ack?.(sequenceID)
|
|
84
|
+
} : {}
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
...hub.events != null ? {
|
|
88
|
+
events: hub.events
|
|
89
|
+
} : {}
|
|
90
|
+
};
|
|
91
|
+
return view;
|
|
92
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { MailboxHub } from '@kumiai/hub-tunnel';
|
|
2
|
+
import type { DeviceHub } from './hub-like.js';
|
|
3
|
+
export type SenderScopedHubViewParams = {
|
|
4
|
+
/** The device-wide hub (from `createHubLike`), whose drain every lane shares. */
|
|
5
|
+
hub: DeviceHub;
|
|
6
|
+
/** Only frames the hub authenticated as sent by this DID reach the consumer. */
|
|
7
|
+
peerDID: string;
|
|
8
|
+
/**
|
|
9
|
+
* How long a publish on this session may wait for a hub connection before it
|
|
10
|
+
* is failed. Omitted leaves the device hub's own behaviour, which is to WAIT.
|
|
11
|
+
*
|
|
12
|
+
* That wait is right for the lane it was built for: a commit authored while
|
|
13
|
+
* the hub is away should land when it returns, and the invite lane is built on
|
|
14
|
+
* it. It is wrong here. A directed session is opened because someone pressed
|
|
15
|
+
* something, and the device hub's `#ready` awaits one reconnect gate after
|
|
16
|
+
* another for as long as the hub stays down — so with no hub, `syncPeer`
|
|
17
|
+
* returned neither a result nor an error, and a screen awaiting it held its
|
|
18
|
+
* spinner indefinitely.
|
|
19
|
+
*/
|
|
20
|
+
publishTimeoutMs?: number;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* A view over one device's hub whose drain carries one peer's frames only.
|
|
24
|
+
*
|
|
25
|
+
* A device dialing two peers at once receives both answers on the same topic —
|
|
26
|
+
* its own dialer topic for the (group, epoch) — so an unfiltered drain hands
|
|
27
|
+
* each session the other's frames. Opening one consumes an MLS ratchet
|
|
28
|
+
* generation and DESTROYS it for the session it belonged to, which starves that
|
|
29
|
+
* session to its idle timeout with nothing raised anywhere.
|
|
30
|
+
*
|
|
31
|
+
* The filtering is asked of the hub rather than done here: a sink parks a
|
|
32
|
+
* single waiter, so a view that filtered by calling
|
|
33
|
+
* `next()` on the consumer's behalf would take that slot and starve the session
|
|
34
|
+
* it was meant to protect — measurably, even when it drops nothing at all. That
|
|
35
|
+
* is why this is not {@link createPeerScopedHubView}, which a RESPONDER uses:
|
|
36
|
+
* a responder's drain is long-lived and shared across its spawns, and it owns
|
|
37
|
+
* the topic subscription for its whole life. A caller's is neither, so
|
|
38
|
+
* `subscribe` and `unsubscribe` pass straight through to the session's own
|
|
39
|
+
* lifecycle.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createSenderScopedHubView({ hub, peerDID, publishTimeoutMs, }: SenderScopedHubViewParams): MailboxHub;
|