@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,57 @@
|
|
|
1
|
+
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
2
|
+
/**
|
|
3
|
+
* `extensions.code` carried by every group-authority refusal, on the local
|
|
4
|
+
* GraphQL path and on the wire alike. The specific refusal travels beside it as
|
|
5
|
+
* `extensions.reason`, so one category covers many refusals without a code per
|
|
6
|
+
* refusal — and only a registered code may leave the device at all.
|
|
7
|
+
*/
|
|
8
|
+
export declare const GROUP_CONTROL_DENIED = "KB14";
|
|
9
|
+
/**
|
|
10
|
+
* `extensions.reason` carried by every admin-gate refusal, under
|
|
11
|
+
* `extensions.code = 'KB14'`. An app matches on the pair rather than on the
|
|
12
|
+
* message, so the wording can change without breaking a caller that hides or
|
|
13
|
+
* disables an admin-only control.
|
|
14
|
+
*/
|
|
15
|
+
export declare const NOT_GROUP_ADMIN = "NOT_GROUP_ADMIN";
|
|
16
|
+
/**
|
|
17
|
+
* `extensions.reason` carried when a group's only admin tries to leave it,
|
|
18
|
+
* under `extensions.code = 'KB14'`.
|
|
19
|
+
*
|
|
20
|
+
* Leaving revokes the leaver's own control authority, and that revocation has to
|
|
21
|
+
* be one the group accepts: the roster fold drops an entry that would leave zero
|
|
22
|
+
* admins, so the last admin's self-demotion could never take effect. Departing
|
|
23
|
+
* anyway would strand the group with an admin who holds authority everywhere and
|
|
24
|
+
* is present nowhere. The app's move is to promote a successor first.
|
|
25
|
+
*/
|
|
26
|
+
export declare const LAST_GROUP_ADMIN = "LAST_GROUP_ADMIN";
|
|
27
|
+
export type RequireGroupAdminParams = {
|
|
28
|
+
store: P2PStoreAPI;
|
|
29
|
+
groupID: string;
|
|
30
|
+
/** The DID whose authority is being checked — the caller, not the target. */
|
|
31
|
+
did: string;
|
|
32
|
+
/** Phrase completing "only a group admin can ..." in the refusal message. */
|
|
33
|
+
action: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Refuse unless `did` is an admin of `groupID` right now.
|
|
37
|
+
*
|
|
38
|
+
* The answer comes from the membership row's `role`, written only by the roster
|
|
39
|
+
* mirror — the roster folded over the authenticated MLS epoch chain, in
|
|
40
|
+
* enactment order. Authority moves exactly when the commit carrying the change
|
|
41
|
+
* lands, and no signer can place a role change earlier by choosing its own
|
|
42
|
+
* timestamp.
|
|
43
|
+
*
|
|
44
|
+
* Reading the row rather than the roster is deliberate: a role lives ON a
|
|
45
|
+
* membership row, so reading it this way implies membership, and the mirror's
|
|
46
|
+
* update-only write can never conjure a row. The roster is DID-keyed and can
|
|
47
|
+
* carry a role for a DID holding no MLS leaf, so consulting it directly drops
|
|
48
|
+
* the membership half of the check silently. `isGroupAdmin` also applies the
|
|
49
|
+
* membership check's removal-boundary semantics, so a tombstoned member is
|
|
50
|
+
* never an admin.
|
|
51
|
+
*
|
|
52
|
+
* Advisory, as every local check is: the ultimate authority is each peer's own
|
|
53
|
+
* fold, which drops a non-admin's entry regardless. Refusing here turns a
|
|
54
|
+
* silently discarded write into a loud, catchable failure — a stale gate is a
|
|
55
|
+
* stale button, never a hole.
|
|
56
|
+
*/
|
|
57
|
+
export declare function requireGroupAdmin(params: RequireGroupAdminParams): Promise<void>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { GraphQLError } from 'graphql';
|
|
2
|
+
/**
|
|
3
|
+
* `extensions.code` carried by every group-authority refusal, on the local
|
|
4
|
+
* GraphQL path and on the wire alike. The specific refusal travels beside it as
|
|
5
|
+
* `extensions.reason`, so one category covers many refusals without a code per
|
|
6
|
+
* refusal — and only a registered code may leave the device at all.
|
|
7
|
+
*/ export const GROUP_CONTROL_DENIED = 'KB14';
|
|
8
|
+
/**
|
|
9
|
+
* `extensions.reason` carried by every admin-gate refusal, under
|
|
10
|
+
* `extensions.code = 'KB14'`. An app matches on the pair rather than on the
|
|
11
|
+
* message, so the wording can change without breaking a caller that hides or
|
|
12
|
+
* disables an admin-only control.
|
|
13
|
+
*/ export const NOT_GROUP_ADMIN = 'NOT_GROUP_ADMIN';
|
|
14
|
+
/**
|
|
15
|
+
* `extensions.reason` carried when a group's only admin tries to leave it,
|
|
16
|
+
* under `extensions.code = 'KB14'`.
|
|
17
|
+
*
|
|
18
|
+
* Leaving revokes the leaver's own control authority, and that revocation has to
|
|
19
|
+
* be one the group accepts: the roster fold drops an entry that would leave zero
|
|
20
|
+
* admins, so the last admin's self-demotion could never take effect. Departing
|
|
21
|
+
* anyway would strand the group with an admin who holds authority everywhere and
|
|
22
|
+
* is present nowhere. The app's move is to promote a successor first.
|
|
23
|
+
*/ export const LAST_GROUP_ADMIN = 'LAST_GROUP_ADMIN';
|
|
24
|
+
/**
|
|
25
|
+
* Refuse unless `did` is an admin of `groupID` right now.
|
|
26
|
+
*
|
|
27
|
+
* The answer comes from the membership row's `role`, written only by the roster
|
|
28
|
+
* mirror — the roster folded over the authenticated MLS epoch chain, in
|
|
29
|
+
* enactment order. Authority moves exactly when the commit carrying the change
|
|
30
|
+
* lands, and no signer can place a role change earlier by choosing its own
|
|
31
|
+
* timestamp.
|
|
32
|
+
*
|
|
33
|
+
* Reading the row rather than the roster is deliberate: a role lives ON a
|
|
34
|
+
* membership row, so reading it this way implies membership, and the mirror's
|
|
35
|
+
* update-only write can never conjure a row. The roster is DID-keyed and can
|
|
36
|
+
* carry a role for a DID holding no MLS leaf, so consulting it directly drops
|
|
37
|
+
* the membership half of the check silently. `isGroupAdmin` also applies the
|
|
38
|
+
* membership check's removal-boundary semantics, so a tombstoned member is
|
|
39
|
+
* never an admin.
|
|
40
|
+
*
|
|
41
|
+
* Advisory, as every local check is: the ultimate authority is each peer's own
|
|
42
|
+
* fold, which drops a non-admin's entry regardless. Refusing here turns a
|
|
43
|
+
* silently discarded write into a loud, catchable failure — a stale gate is a
|
|
44
|
+
* stale button, never a hole.
|
|
45
|
+
*/ export async function requireGroupAdmin(params) {
|
|
46
|
+
const { store, groupID, did, action } = params;
|
|
47
|
+
if (await store.isGroupAdmin(groupID, did)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
throw new GraphQLError(`not authorized: only a group admin can ${action}`, {
|
|
51
|
+
extensions: {
|
|
52
|
+
code: GROUP_CONTROL_DENIED,
|
|
53
|
+
reason: NOT_GROUP_ADMIN,
|
|
54
|
+
groupID
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
package/lib/context/sync.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
import type { SyncRequestContext } from '../types.js';
|
|
2
2
|
import type { ContextDeps } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Why a candidate failed, for a caller that gets only the outcome.
|
|
5
|
+
*
|
|
6
|
+
* The WHOLE cause chain: the outer links are the ones every transport failure
|
|
7
|
+
* shares (`Transport read failed` → `AbortInterruption` → …), so stopping early
|
|
8
|
+
* makes an eviction, a hub outage and a phone that is off read identically. A
|
|
9
|
+
* removal is four links down.
|
|
10
|
+
*
|
|
11
|
+
* A link with no message is stepped over, not stopped at — giving up there is
|
|
12
|
+
* how a deeper unwrap says less than the one-level version.
|
|
13
|
+
*/
|
|
14
|
+
export declare function describeFailure(error: unknown): string | undefined;
|
|
3
15
|
export declare function createSyncContext(_ctx: unknown, deps: ContextDeps): SyncRequestContext;
|
package/lib/context/sync.js
CHANGED
|
@@ -1 +1,252 @@
|
|
|
1
|
-
|
|
1
|
+
import { getGraphStore } from '@kubun/store-graph';
|
|
2
|
+
import { getP2PStore } from '@kubun/store-p2p';
|
|
3
|
+
import { rankSyncPeers } from '../groups/peer-selection.js';
|
|
4
|
+
import { resolveCatalogSyncScopes } from '../sync/catalog-scope.js';
|
|
5
|
+
const NO_OP = {
|
|
6
|
+
messagesReceived: 0,
|
|
7
|
+
messagesSent: 0,
|
|
8
|
+
divergentBuckets: 0
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Why a candidate failed, for a caller that gets only the outcome.
|
|
12
|
+
*
|
|
13
|
+
* The WHOLE cause chain: the outer links are the ones every transport failure
|
|
14
|
+
* shares (`Transport read failed` → `AbortInterruption` → …), so stopping early
|
|
15
|
+
* makes an eviction, a hub outage and a phone that is off read identically. A
|
|
16
|
+
* removal is four links down.
|
|
17
|
+
*
|
|
18
|
+
* A link with no message is stepped over, not stopped at — giving up there is
|
|
19
|
+
* how a deeper unwrap says less than the one-level version.
|
|
20
|
+
*/ export function describeFailure(error) {
|
|
21
|
+
if (!(error instanceof Error)) {
|
|
22
|
+
return error == null ? undefined : String(error);
|
|
23
|
+
}
|
|
24
|
+
const messages = [];
|
|
25
|
+
// Bounded by identity: nothing here emits a cycle, but this runs on the
|
|
26
|
+
// failure path, where the alternative is hanging while reporting a failure.
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
let current = error;
|
|
29
|
+
while(current instanceof Error && !seen.has(current)){
|
|
30
|
+
seen.add(current);
|
|
31
|
+
if (current.message !== '') {
|
|
32
|
+
messages.push(current.message);
|
|
33
|
+
}
|
|
34
|
+
current = current.cause;
|
|
35
|
+
}
|
|
36
|
+
return messages.join(': ');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* What the local device has opted into pulling: only ACTIVE catalogs contribute
|
|
40
|
+
* (`resolveCatalogSyncScopes` skips known/unknown ones), so a device that has
|
|
41
|
+
* activated nothing syncs nothing. The owners come from each active catalog's
|
|
42
|
+
* circle members — never hand-built pairs.
|
|
43
|
+
*
|
|
44
|
+
* Shared by both catch-up entries, so an automatically-chosen peer and a
|
|
45
|
+
* user-named one cannot be asked for different scopes.
|
|
46
|
+
*/ async function resolveActiveScopes(stores) {
|
|
47
|
+
const graphStore = await getGraphStore(stores);
|
|
48
|
+
const activeCatalogs = await graphStore.listStoredCatalogs({
|
|
49
|
+
active: true
|
|
50
|
+
});
|
|
51
|
+
const scope = await resolveCatalogSyncScopes(stores, activeCatalogs.map((catalog)=>catalog.id));
|
|
52
|
+
const owners = scope.owners ?? [];
|
|
53
|
+
return {
|
|
54
|
+
scopes: scope.modelIDs.flatMap((modelID)=>owners.map((ownerDID)=>({
|
|
55
|
+
modelID,
|
|
56
|
+
ownerDID
|
|
57
|
+
}))),
|
|
58
|
+
// What this device HAS, not what its catalogs name. A catalog may name a
|
|
59
|
+
// model that was never deployed here — that is the case the peer answers by
|
|
60
|
+
// shipping the model's cluster — so deriving this from the scope would
|
|
61
|
+
// declare every such model known and suppress the very definition needed.
|
|
62
|
+
knownModelIDs: await graphStore.listDocumentModelIDs()
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function createSyncContext(_ctx, deps) {
|
|
66
|
+
/**
|
|
67
|
+
* Where to reach a co-member, preferring the group's hub tunnel.
|
|
68
|
+
*
|
|
69
|
+
* A peer with a row in the group's discovery projection is reachable through
|
|
70
|
+
* that group by construction: the row was written from an MLS-authenticated
|
|
71
|
+
* announce, and `tunnel://<groupID>/<peerDID>` needs nothing else. `undefined`
|
|
72
|
+
* hands the question back to the persisted registry, which is what a peer
|
|
73
|
+
* added by URL — an HTTP server, an in-process test peer — still needs.
|
|
74
|
+
*
|
|
75
|
+
* Route only. Which documents a session serves is decided per document at the
|
|
76
|
+
* far end, so preferring a tunnel changes who can be dialled and nothing about
|
|
77
|
+
* what comes back.
|
|
78
|
+
*/ const resolveRoute = async (groupID, peerDID)=>{
|
|
79
|
+
const p2pStore = await getP2PStore(deps.stores);
|
|
80
|
+
const announcement = await p2pStore.getPeerAnnouncement({
|
|
81
|
+
groupID,
|
|
82
|
+
peerDID
|
|
83
|
+
});
|
|
84
|
+
return announcement == null ? undefined : `tunnel://${groupID}/${peerDID}`;
|
|
85
|
+
};
|
|
86
|
+
// Bidirectional: each device pushes the docs it owns (its own owner-scope
|
|
87
|
+
// authorizes) and pulls what it may. Reaching the peer needs no prior
|
|
88
|
+
// `connectPeer` either way — the projection and the registry are both
|
|
89
|
+
// persisted, so this works on the first call after a restart.
|
|
90
|
+
const runCatchUp = async (groupID, peerDID)=>{
|
|
91
|
+
const { scopes, knownModelIDs } = await resolveActiveScopes(deps.stores);
|
|
92
|
+
// No active catalog (or none resolvable to a concrete owner) → the opt-in
|
|
93
|
+
// resting state. A clean all-zero no-op, never an error and never a peer
|
|
94
|
+
// round-trip.
|
|
95
|
+
if (scopes.length === 0) {
|
|
96
|
+
return NO_OP;
|
|
97
|
+
}
|
|
98
|
+
const endpoint = await resolveRoute(groupID, peerDID);
|
|
99
|
+
const result = await deps.syncManager.merkleSyncWithPeer({
|
|
100
|
+
peerDID,
|
|
101
|
+
scopes,
|
|
102
|
+
stores: deps.stores,
|
|
103
|
+
knownModelIDs,
|
|
104
|
+
direction: 'both',
|
|
105
|
+
...endpoint == null ? {} : {
|
|
106
|
+
endpoint
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
messagesReceived: result.messagesReceived,
|
|
111
|
+
messagesSent: result.messagesSent,
|
|
112
|
+
divergentBuckets: result.divergentBuckets
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
addPeer: (config)=>deps.syncManager.addPeer({
|
|
117
|
+
config,
|
|
118
|
+
stores: deps.stores
|
|
119
|
+
}),
|
|
120
|
+
removePeer: (peerDID)=>deps.syncManager.removePeer({
|
|
121
|
+
peerDID,
|
|
122
|
+
stores: deps.stores
|
|
123
|
+
}),
|
|
124
|
+
listPeers: ()=>deps.syncManager.listPeers({
|
|
125
|
+
stores: deps.stores
|
|
126
|
+
}),
|
|
127
|
+
getStatus: (peerDID)=>deps.syncManager.getStatus({
|
|
128
|
+
peerDID,
|
|
129
|
+
stores: deps.stores
|
|
130
|
+
}),
|
|
131
|
+
// The user named the peer, so the ranking is not consulted: intent beats
|
|
132
|
+
// policy, and this is what makes phone-to-phone "I just changed this, show me
|
|
133
|
+
// now" work against a device automatic selection would never pick. A failure
|
|
134
|
+
// is raised rather than reported, for the same reason: someone is watching.
|
|
135
|
+
syncPeer: async (groupID, peerDID)=>{
|
|
136
|
+
// Tunnel topics derive from the MLS epoch, so a roster commit landing
|
|
137
|
+
// mid-session kills it. A fresh session derives the new topics.
|
|
138
|
+
//
|
|
139
|
+
// ARMED BY THE ROTATION, never by the error: matching `TransportDisposed`
|
|
140
|
+
// would swallow a session torn down for any other reason, and retrying
|
|
141
|
+
// every failure would double the wait against an unreachable peer.
|
|
142
|
+
//
|
|
143
|
+
// A flag from the listener, not a before/after read of `groupEpoch` — the
|
|
144
|
+
// rotation can land between the failure and the re-read. The listener runs
|
|
145
|
+
// inside the registry's mutex, so it may only set a boolean.
|
|
146
|
+
let rotated = false;
|
|
147
|
+
const stopWatching = deps.registry.onEpochChanged((changed)=>{
|
|
148
|
+
if (changed === groupID) {
|
|
149
|
+
rotated = true;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
try {
|
|
154
|
+
return await runCatchUp(groupID, peerDID);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (!rotated) {
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
deps.logger.debug('retrying a sync the group epoch moved under', {
|
|
160
|
+
groupID,
|
|
161
|
+
peerDID
|
|
162
|
+
});
|
|
163
|
+
// ONCE: two consecutive broken sessions means "not now".
|
|
164
|
+
return await runCatchUp(groupID, peerDID);
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
// Named, because the raw message is routinely `Transport read failed` —
|
|
168
|
+
// useless to a screen that must say which row it could not reach. Only
|
|
169
|
+
// this layer holds both the peer and the unwrapped cause.
|
|
170
|
+
throw new Error(`sync with ${peerDID} failed: ${describeFailure(error) ?? 'unknown error'}`, {
|
|
171
|
+
cause: error
|
|
172
|
+
});
|
|
173
|
+
} finally{
|
|
174
|
+
stopWatching();
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
catchUpWithBestPeer: async (groupID)=>{
|
|
178
|
+
const { scopes } = await resolveActiveScopes(deps.stores);
|
|
179
|
+
if (scopes.length === 0) {
|
|
180
|
+
return {
|
|
181
|
+
peerDID: null,
|
|
182
|
+
outcome: 'no-scopes',
|
|
183
|
+
...NO_OP
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const p2pStore = await getP2PStore(deps.stores);
|
|
187
|
+
const rows = await p2pStore.listPeerAnnouncements(groupID);
|
|
188
|
+
const candidates = rankSyncPeers(rows, {
|
|
189
|
+
selfDID: deps.identity.id
|
|
190
|
+
});
|
|
191
|
+
if (candidates.length === 0) {
|
|
192
|
+
return {
|
|
193
|
+
peerDID: null,
|
|
194
|
+
outcome: 'no-candidates',
|
|
195
|
+
...NO_OP
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// Down the ranking until one answers. A row records that a device once
|
|
199
|
+
// announced, never that it is on now — and no cheaper probe exists, so a
|
|
200
|
+
// failed session IS the reachability test. Bounded by the transport's own
|
|
201
|
+
// idle timeout, which is why the tunnel resolver sets one.
|
|
202
|
+
let lastPeerDID = null;
|
|
203
|
+
let lastFailure;
|
|
204
|
+
for (const candidate of candidates){
|
|
205
|
+
lastPeerDID = candidate.peer_did;
|
|
206
|
+
try {
|
|
207
|
+
return {
|
|
208
|
+
peerDID: candidate.peer_did,
|
|
209
|
+
outcome: 'synced',
|
|
210
|
+
...await runCatchUp(groupID, candidate.peer_did)
|
|
211
|
+
};
|
|
212
|
+
} catch (error) {
|
|
213
|
+
// Nothing was reached, so nothing was learned about this peer beyond
|
|
214
|
+
// "not now". The next candidate is the whole remedy — but a run that
|
|
215
|
+
// walks every candidate and reports `no-route` looks identical whether
|
|
216
|
+
// the devices are off or the local apply is throwing, and only this
|
|
217
|
+
// line tells them apart.
|
|
218
|
+
//
|
|
219
|
+
// At `warn`, not `debug`: the OUTCOME ships at info, so a build that
|
|
220
|
+
// drops debug reports that every candidate failed while discarding the
|
|
221
|
+
// only record of why. That is the shape a device QA session hit — the
|
|
222
|
+
// reason existed and was below the level anyone was reading.
|
|
223
|
+
lastFailure = error;
|
|
224
|
+
deps.logger.warn('catch-up candidate failed', {
|
|
225
|
+
groupID,
|
|
226
|
+
peerDID: candidate.peer_did,
|
|
227
|
+
error
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const failureReason = describeFailure(lastFailure);
|
|
232
|
+
return {
|
|
233
|
+
peerDID: lastPeerDID,
|
|
234
|
+
outcome: 'no-route',
|
|
235
|
+
...NO_OP,
|
|
236
|
+
...failureReason == null ? {} : {
|
|
237
|
+
failureReason
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
listPeerDevices: async (groupID)=>{
|
|
242
|
+
const p2pStore = await getP2PStore(deps.stores);
|
|
243
|
+
return (await p2pStore.listPeerAnnouncements(groupID)).map((row)=>({
|
|
244
|
+
peerDID: row.peer_did,
|
|
245
|
+
label: row.label,
|
|
246
|
+
availability: row.availability,
|
|
247
|
+
capabilities: row.capabilities,
|
|
248
|
+
hlc: row.hlc
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
package/lib/context/types.d.ts
CHANGED
|
@@ -1,65 +1,101 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { Runtime } from '@enkaku/runtime';
|
|
3
|
-
import type { OwnIdentity } from '@enkaku/token';
|
|
1
|
+
import type { OwnIdentity } from '@kokuin/token';
|
|
4
2
|
import type { StoreProvider } from '@kubun/db';
|
|
5
3
|
import type { Adapter } from '@kubun/db-adapter';
|
|
6
4
|
import type { HLC } from '@kubun/hlc';
|
|
7
5
|
import type { Logger } from '@kubun/logger';
|
|
6
|
+
import type { Circle } from '@kubun/store-p2p';
|
|
7
|
+
import type { LaneResult, PendingCommit } from '@kumiai/rpc';
|
|
8
|
+
import type { Runtime } from '@sozai/runtime';
|
|
8
9
|
import type { GroupBroadcastMessage } from '../groups/broadcast.js';
|
|
9
10
|
import type { P2PEventEmitter } from '../groups/events.js';
|
|
10
11
|
import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
|
|
11
12
|
import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
|
|
12
13
|
import type { GroupManager } from '../groups/manager.js';
|
|
14
|
+
import type { PeerConnectionRegistry } from '../peer/connection-registry.js';
|
|
13
15
|
import type { SyncManager } from '../sync/sync-manager.js';
|
|
14
|
-
import type { RejoinResult } from '../types.js';
|
|
15
|
-
export type PendingJoinRequest = {
|
|
16
|
-
publicPackage: KeyPackageBundle['publicPackage'];
|
|
17
|
-
privatePackage: KeyPackageBundle['privatePackage'];
|
|
18
|
-
did: string;
|
|
19
|
-
};
|
|
16
|
+
import type { CircleData, LedgerCatchupOptions, LedgerCatchupSummary, RejoinResult } from '../types.js';
|
|
20
17
|
export type ContextDeps = {
|
|
21
18
|
identity: OwnIdentity;
|
|
22
19
|
groupManager: GroupManager;
|
|
23
20
|
registry: GroupHandleRegistry;
|
|
24
21
|
syncManager: SyncManager;
|
|
22
|
+
/**
|
|
23
|
+
* Plugin-scoped in-memory registry of live peer connections, keyed by
|
|
24
|
+
* `peerDID`. `connectPeer` writes here; nothing is persisted.
|
|
25
|
+
*/
|
|
26
|
+
peerConnections: PeerConnectionRegistry;
|
|
25
27
|
stores: StoreProvider;
|
|
28
|
+
/**
|
|
29
|
+
* The DEVICE-wide store provider, which `stores` is not: under `mutateGraph`
|
|
30
|
+
* that one is the request's own transaction, and a write made through it after
|
|
31
|
+
* the commit hook has fired never lands — it waits on a transaction that is
|
|
32
|
+
* already over. Work deferred past the mutation must use this instead.
|
|
33
|
+
*/
|
|
34
|
+
deviceStores: StoreProvider;
|
|
26
35
|
adapter: Adapter;
|
|
27
36
|
hlc: HLC;
|
|
28
37
|
emitter: P2PEventEmitter;
|
|
29
|
-
pendingJoinRequests: Map<string, PendingJoinRequest>;
|
|
30
38
|
runtime: Runtime;
|
|
31
39
|
autoAcceptPeers?: Array<string>;
|
|
32
40
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
41
|
+
* How long `requestInvite` waits for its own Add commit to settle before
|
|
42
|
+
* returning the truthful pending shape (a parked commit, inviter offline).
|
|
43
|
+
* Test-only escape hatch — production omits it and runs on the 30s default;
|
|
44
|
+
* lets a headless test drive the park-then-pending path without waiting out
|
|
45
|
+
* the full production timeout.
|
|
46
|
+
*/
|
|
47
|
+
inviteCommitTimeoutMs?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Fire-and-forget hub broadcast trigger. Wired to `HubWiring.scheduleBroadcast`;
|
|
50
|
+
* a no-op for a group with no hub-backed peer to fan out to. Handlers invoke
|
|
51
|
+
* this via `stores.onCommit(...)` so the hub send runs after the mutation
|
|
52
|
+
* transaction commits.
|
|
37
53
|
*/
|
|
38
54
|
scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
|
|
39
55
|
/**
|
|
40
56
|
* Awaitable broadcast (encrypt + send completed on resolve). Wired to
|
|
41
|
-
* `HubWiring.broadcastNow`; a no-op resolve
|
|
57
|
+
* `HubWiring.broadcastNow`; a no-op resolve for a group with no hub-backed
|
|
58
|
+
* peer. Used where
|
|
42
59
|
* the send must complete before a following local op — e.g. a leave-request
|
|
43
60
|
* encrypted while the MLS handle is still live, before `leaveGroup`.
|
|
44
61
|
*/
|
|
45
62
|
broadcastNow: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
|
|
46
63
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
64
|
+
* Drive a commit through the group's canonical commit hub and await its
|
|
65
|
+
* outcome. Wired to `HubWiring.commitToGroup`. Used by the control-ledger
|
|
66
|
+
* enact path: the admin's role entry rides the commit's bodies, published to
|
|
67
|
+
* the commit log, and the local MLS handle is advanced only when the log
|
|
68
|
+
* accepts. A group with no hub bound publishes to its in-process loopback log,
|
|
69
|
+
* so the enact path is available with or without a hub.
|
|
51
70
|
*/
|
|
52
|
-
|
|
71
|
+
commitToGroup: (groupID: string, build: () => Promise<PendingCommit>) => Promise<LaneResult>;
|
|
53
72
|
/** Shared group-health monitor; source for the `groupHealth` query. */
|
|
54
73
|
monitor: GroupHealthMonitor;
|
|
55
74
|
/**
|
|
56
|
-
* Run stale-device recovery for a group. Wired to `HubWiring.rejoinGroup
|
|
57
|
-
* a
|
|
75
|
+
* Run stale-device recovery for a group. Wired to `HubWiring.rejoinGroup`,
|
|
76
|
+
* which recovers through the rendezvous when a live member answers — reporting
|
|
77
|
+
* `recovered` on success, or `recovery-failed` (without throwing) when no
|
|
78
|
+
* responder is reachable.
|
|
58
79
|
*/
|
|
59
80
|
rejoinGroup: (groupID: string) => Promise<RejoinResult>;
|
|
81
|
+
/**
|
|
82
|
+
* Gather the group's whole control ledger from its current members and adopt
|
|
83
|
+
* it locally, verified against this device's authenticated head. Wired to
|
|
84
|
+
* `HubWiring.requestLedgerCatchup`; a no-op all-zero summary for a group with
|
|
85
|
+
* no hub-backed peer to gather from, or when every responder's ledger was
|
|
86
|
+
* refused. The drift/backstop path for a joiner whose invite snapshot missed
|
|
87
|
+
* entries appended after the invite was minted.
|
|
88
|
+
*/
|
|
89
|
+
requestLedgerCatchup: (groupID: string, options?: LedgerCatchupOptions) => Promise<LedgerCatchupSummary>;
|
|
60
90
|
logger: Logger;
|
|
61
91
|
};
|
|
62
92
|
export declare function toISO(timestamp: unknown): string;
|
|
93
|
+
/**
|
|
94
|
+
* The one circle row → API mapping, so every producer (queries, events, mutation
|
|
95
|
+
* payloads) reports the same shape — notably the projected `open` flag, stored as
|
|
96
|
+
* integer 0/1 to read back uniformly across SQLite and Postgres.
|
|
97
|
+
*/
|
|
98
|
+
export declare function toCircleData(row: Circle): CircleData;
|
|
63
99
|
/**
|
|
64
100
|
* Normalize a credential value from the DB.
|
|
65
101
|
* Kysely's ParseJSONResultsPlugin may have already parsed the JSON string
|
package/lib/context/types.js
CHANGED
|
@@ -1 +1,48 @@
|
|
|
1
|
-
export function toISO(
|
|
1
|
+
export function toISO(timestamp) {
|
|
2
|
+
if (typeof timestamp === 'number') {
|
|
3
|
+
return new Date(timestamp * 1000).toISOString();
|
|
4
|
+
}
|
|
5
|
+
if (timestamp instanceof Date) {
|
|
6
|
+
return timestamp.toISOString();
|
|
7
|
+
}
|
|
8
|
+
return String(timestamp);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The one circle row → API mapping, so every producer (queries, events, mutation
|
|
12
|
+
* payloads) reports the same shape — notably the projected `open` flag, stored as
|
|
13
|
+
* integer 0/1 to read back uniformly across SQLite and Postgres.
|
|
14
|
+
*/ export function toCircleData(row) {
|
|
15
|
+
return {
|
|
16
|
+
id: row.id,
|
|
17
|
+
groupID: row.group_id,
|
|
18
|
+
name: row.name,
|
|
19
|
+
description: row.description,
|
|
20
|
+
open: row.open === 1,
|
|
21
|
+
createdAt: toISO(row.created_at)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Normalize a credential value from the DB.
|
|
26
|
+
* Kysely's ParseJSONResultsPlugin may have already parsed the JSON string
|
|
27
|
+
* into an object. SerializedMLSGroupState expects a JSON string.
|
|
28
|
+
*/ export function normalizeCredential(value) {
|
|
29
|
+
if (typeof value === 'string') return value;
|
|
30
|
+
return JSON.stringify(value);
|
|
31
|
+
}
|
|
32
|
+
export function checkPeerAccess(viewerDID, identityID, autoAcceptPeers) {
|
|
33
|
+
// The device operator is always allowed to run its own peer ops. The
|
|
34
|
+
// allow-list gates OTHER DIDs (remote callers of the dance handlers); it must
|
|
35
|
+
// never lock the device out of inviting/joining on its own behalf — an
|
|
36
|
+
// inviter that trusts a peer via `autoAcceptPeers` still runs the invite as
|
|
37
|
+
// itself, and a joiner drives its own inbound dance locally.
|
|
38
|
+
if (viewerDID === identityID) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (autoAcceptPeers != null) {
|
|
42
|
+
if (!autoAcceptPeers.includes(viewerDID)) {
|
|
43
|
+
throw new Error('Peer not in autoAcceptPeers list');
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
throw new Error('Only the engine identity can perform this operation');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type OwnIdentity } from '@kokuin/token';
|
|
2
|
+
/** The rule a member states for one of its models. Mirrors the stored shape. */
|
|
3
|
+
export type AccessDefaultRule = {
|
|
4
|
+
level: string;
|
|
5
|
+
allowedDIDs: Array<string> | null;
|
|
6
|
+
allowedCircles: Array<string> | null;
|
|
7
|
+
allowedGroups: Array<string> | null;
|
|
8
|
+
};
|
|
9
|
+
type SetPayload = {
|
|
10
|
+
op: 'set';
|
|
11
|
+
modelID: string;
|
|
12
|
+
permissionType: 'read' | 'write';
|
|
13
|
+
rule: AccessDefaultRule;
|
|
14
|
+
hlc: string;
|
|
15
|
+
};
|
|
16
|
+
type RemovePayload = {
|
|
17
|
+
op: 'remove';
|
|
18
|
+
modelID: string;
|
|
19
|
+
permissionTypes: Array<'read' | 'write'>;
|
|
20
|
+
hlc: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The verified claim behind an access-default broadcast. `issuer` is the
|
|
24
|
+
* authenticated author (the verified token `iss`) — the owner whose sharing
|
|
25
|
+
* policy this is. Every authoritative field comes from the signed payload, so a
|
|
26
|
+
* receiver never trusts the surrounding plaintext frame.
|
|
27
|
+
*/
|
|
28
|
+
export type VerifiedAccessDefault = {
|
|
29
|
+
issuer: string;
|
|
30
|
+
claim: SetPayload;
|
|
31
|
+
} | {
|
|
32
|
+
issuer: string;
|
|
33
|
+
claim: RemovePayload;
|
|
34
|
+
};
|
|
35
|
+
export declare function signAccessDefaultSet(identity: OwnIdentity, claim: Omit<SetPayload, 'op'>): Promise<string>;
|
|
36
|
+
export declare function signAccessDefaultRemove(identity: OwnIdentity, claim: Omit<RemovePayload, 'op'>): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* Verify a signed access-default token and extract its claim. Returns `null`
|
|
39
|
+
* (never throws) when the token is unparseable, unsigned (`alg: 'none'`), or
|
|
40
|
+
* structurally malformed — an attacker cannot forge an `iss` this way. The
|
|
41
|
+
* issuer is the normalized verified `iss`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function verifyAccessDefault(token: string): Promise<VerifiedAccessDefault | null>;
|
|
44
|
+
export {};
|