@kubun/plugin-p2p 0.8.3 → 0.10.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.d.ts +4 -0
- package/lib/context/delegation.js +1 -0
- package/lib/context/group.js +1 -1
- package/lib/context/hub.d.ts +4 -0
- package/lib/context/hub.js +1 -0
- package/lib/context/join.js +1 -1
- package/lib/context/types.d.ts +28 -2
- package/lib/groups/broadcast-service.d.ts +130 -17
- package/lib/groups/broadcast-service.js +1 -1
- package/lib/groups/broadcast.d.ts +162 -5
- package/lib/groups/broadcast.js +1 -1
- package/lib/groups/events.d.ts +26 -5
- package/lib/groups/events.js +1 -1
- package/lib/groups/group-handle-registry.d.ts +90 -0
- package/lib/groups/group-handle-registry.js +1 -0
- package/lib/groups/invite-payload.d.ts +21 -1
- package/lib/groups/join-utils.d.ts +68 -2
- package/lib/groups/join-utils.js +1 -1
- package/lib/groups/manager.d.ts +26 -18
- package/lib/groups/manager.js +1 -1
- package/lib/groups/mls-codec.d.ts +7 -0
- package/lib/groups/mls-codec.js +1 -0
- package/lib/groups/mls-encryptor.d.ts +25 -0
- package/lib/groups/mls-encryptor.js +1 -0
- package/lib/groups/store-received-grant.d.ts +47 -0
- package/lib/groups/store-received-grant.js +1 -0
- package/lib/groups/store-received-revocation.d.ts +46 -0
- package/lib/groups/store-received-revocation.js +1 -0
- package/lib/groups/wire-frame.d.ts +32 -0
- package/lib/groups/wire-frame.js +1 -0
- package/lib/hub/connection-pool.d.ts +24 -0
- package/lib/hub/connection-pool.js +1 -1
- package/lib/hub/did-observing-transport.d.ts +64 -0
- package/lib/hub/did-observing-transport.js +1 -0
- package/lib/hub/errors.d.ts +28 -0
- package/lib/hub/errors.js +1 -0
- package/lib/hub/forward-remote-broadcast.d.ts +15 -0
- package/lib/hub/forward-remote-broadcast.js +1 -0
- package/lib/hub/group-channel.d.ts +37 -21
- package/lib/hub/group-channel.js +1 -1
- package/lib/hub/http-client.d.ts +17 -0
- package/lib/hub/http-client.js +1 -0
- package/lib/hub/hub-connection.d.ts +96 -0
- package/lib/hub/hub-connection.js +1 -0
- package/lib/hub/manager.d.ts +117 -0
- package/lib/hub/manager.js +1 -0
- package/lib/hub/receive-handler.d.ts +3 -6
- package/lib/hub/receive-handler.js +1 -1
- package/lib/hub/relay-manager.d.ts +92 -2
- package/lib/hub/relay-manager.js +1 -1
- package/lib/hub/send-handler.d.ts +28 -16
- package/lib/hub/send-handler.js +1 -1
- package/lib/hub/tunnel-inbox.d.ts +20 -0
- package/lib/hub/tunnel-inbox.js +1 -0
- package/lib/hub/wait-for-gate.d.ts +14 -0
- package/lib/hub/wait-for-gate.js +1 -0
- package/lib/hub/wiring.d.ts +85 -0
- package/lib/hub/wiring.js +1 -0
- package/lib/index.d.ts +102 -7
- package/lib/index.js +1 -1
- package/lib/schema.d.ts +2 -2
- package/lib/schema.js +113 -21
- package/lib/sync/broadcast-queue.d.ts +59 -0
- package/lib/sync/broadcast-queue.js +1 -0
- package/lib/sync/broadcast-sender.d.ts +52 -0
- package/lib/sync/broadcast-sender.js +1 -0
- package/lib/sync/forwarder.d.ts +97 -0
- package/lib/sync/forwarder.js +1 -0
- package/lib/sync/handlers.d.ts +20 -1
- package/lib/sync/handlers.js +1 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
- package/lib/sync/hub-tunnel-sync-listener.js +1 -0
- package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
- package/lib/sync/hub-tunnel-sync-provider.js +1 -0
- package/lib/sync/merkle-apply.d.ts +41 -0
- package/lib/sync/merkle-apply.js +1 -1
- package/lib/sync/merkle-channel.d.ts +15 -0
- package/lib/sync/merkle-channel.js +1 -1
- package/lib/sync/receive-access-gate.d.ts +36 -0
- package/lib/sync/receive-access-gate.js +1 -0
- package/lib/sync/scope-resolver.d.ts +32 -0
- package/lib/sync/scope-resolver.js +1 -0
- package/lib/sync/sync-manager.d.ts +10 -0
- package/lib/sync/sync-manager.js +1 -1
- package/lib/types.d.ts +151 -1
- package/lib/util/mutex.d.ts +4 -0
- package/lib/util/mutex.js +1 -0
- package/package.json +40 -37
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Disposer } from '@enkaku/async';
|
|
2
|
+
import type { EventEmitter } from '@enkaku/event';
|
|
3
|
+
import type { AnyClientMessageOf, AnyServerMessageOf, ClientTransportOf, ProtocolDefinition } from '@enkaku/protocol';
|
|
4
|
+
import type { TransportEvents } from '@enkaku/transport';
|
|
5
|
+
export type ServerDIDObserver = (serverDID: string) => void | Promise<void>;
|
|
6
|
+
export type ServerDIDMismatchObserver = (params: {
|
|
7
|
+
hubURL: string;
|
|
8
|
+
expectedServerDID: string;
|
|
9
|
+
observedServerDID: string;
|
|
10
|
+
}) => void;
|
|
11
|
+
export type DIDObservingTransportParams<Protocol extends ProtocolDefinition> = {
|
|
12
|
+
inner: ClientTransportOf<Protocol>;
|
|
13
|
+
/**
|
|
14
|
+
* Fired on the first message bearing `payload.iss` when no `expectedServerDID`
|
|
15
|
+
* is set (TOFU capture). When `expectedServerDID` is set and matches, the
|
|
16
|
+
* callback is NOT fired — the pin is already authoritative.
|
|
17
|
+
*/
|
|
18
|
+
onServerDID: ServerDIDObserver;
|
|
19
|
+
/**
|
|
20
|
+
* Pinned hub server DID. When set, the wrapper enforces it: a non-matching
|
|
21
|
+
* first observed `iss` triggers a permanent failed state — `read()` rejects
|
|
22
|
+
* with `HubServerDIDMismatchError`, the offending message is NOT delivered
|
|
23
|
+
* to the inner Client, and every subsequent `read()` re-raises the same
|
|
24
|
+
* error. When unset, the wrapper falls back to TOFU capture.
|
|
25
|
+
*/
|
|
26
|
+
expectedServerDID?: string;
|
|
27
|
+
/** Hub URL — only used to populate `HubServerDIDMismatchError` for callers. */
|
|
28
|
+
hubURL?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Optional side-channel notification fired on mismatch. Lets the pool log /
|
|
31
|
+
* evict without having to inspect the rejected `read()` cause. Inner
|
|
32
|
+
* transport disposal is the pool's job, not the wrapper's.
|
|
33
|
+
*/
|
|
34
|
+
onMismatch?: ServerDIDMismatchObserver;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Wraps a `ClientTransportOf<Protocol>` to enforce hub-server-DID
|
|
38
|
+
* TOFU + pinned-mismatch semantics on the receive side.
|
|
39
|
+
*
|
|
40
|
+
* - `expectedServerDID == null`: TOFU mode. First message bearing
|
|
41
|
+
* `payload.iss` fires `onServerDID(iss)` exactly once; subsequent messages
|
|
42
|
+
* never re-fire, even with a differing `iss`. Every message is forwarded
|
|
43
|
+
* to the inner Client untouched.
|
|
44
|
+
* - `expectedServerDID != null` and the first observed `iss` matches: the
|
|
45
|
+
* pin is already authoritative; `onServerDID` is NOT fired and forwarding
|
|
46
|
+
* continues normally.
|
|
47
|
+
* - `expectedServerDID != null` and the first observed `iss` differs: the
|
|
48
|
+
* wrapper enters a permanent failed state (`HubServerDIDMismatchError`).
|
|
49
|
+
* The current `read()` call AND every subsequent `read()` reject with the
|
|
50
|
+
* error; the offending message is NOT delivered. Inner-transport lifecycle
|
|
51
|
+
* stays the pool's responsibility — the wrapper does not dispose its inner.
|
|
52
|
+
*
|
|
53
|
+
* Extends `Disposer` and matches the `ClientTransportOf<Protocol>` shape
|
|
54
|
+
* structurally so it can be passed straight to `Client<Protocol>`.
|
|
55
|
+
*/
|
|
56
|
+
export declare class DIDObservingTransport<Protocol extends ProtocolDefinition> extends Disposer {
|
|
57
|
+
#private;
|
|
58
|
+
constructor(params: DIDObservingTransportParams<Protocol>);
|
|
59
|
+
get events(): EventEmitter<TransportEvents>;
|
|
60
|
+
getWritable(): WritableStream<AnyClientMessageOf<Protocol>>;
|
|
61
|
+
read(): Promise<ReadableStreamReadResult<AnyServerMessageOf<Protocol>>>;
|
|
62
|
+
write(value: AnyClientMessageOf<Protocol>): Promise<void>;
|
|
63
|
+
[Symbol.asyncIterator](): AsyncIterator<AnyServerMessageOf<Protocol>, AnyServerMessageOf<Protocol> | null>;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{Disposer as e}from"@enkaku/async";import{HubServerDIDMismatchError as r}from"./errors.js";export class DIDObservingTransport extends e{#e;#r;#t;#n;#i;#s=!1;#l=null;constructor(e){super({signal:e.inner.signal,dispose:async r=>{await e.inner.dispose(r)}}),this.#e=e.inner,this.#r=e.onServerDID,this.#t=e.expectedServerDID,this.#n=e.hubURL,this.#i=e.onMismatch}get events(){return this.#e.events}getWritable(){return this.#e.getWritable()}async read(){if(null!=this.#l)throw this.#l;let e=await this.#e.read(),r=this.#a(e);if(null!=r)throw r;return e}async write(e){await this.#e.write(e)}[Symbol.asyncIterator](){return{next:async()=>{let e=await this.read();return e.done?{done:!0,value:e.value??null}:{done:!1,value:e.value}}}}#a(e){if(this.#s||e.done||null==e.value)return null;let t=e.value.payload,n=t?.iss;if("string"!=typeof n||0===n.length)return null;if(this.#s=!0,null!=this.#t){if(n===this.#t)return null;let e=new r({hubURL:this.#n??"",expectedServerDID:this.#t,observedServerDID:n});if(this.#l=e,null!=this.#i)try{this.#i({hubURL:this.#n??"",expectedServerDID:this.#t,observedServerDID:n})}catch{}return e}return Promise.resolve().then(()=>this.#r(n)).catch(()=>{}),null}}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare class ReconnectingError extends Error {
|
|
2
|
+
readonly hubURL: string;
|
|
3
|
+
readonly attempt: number;
|
|
4
|
+
constructor(hubURL: string, attempt: number);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Thrown by `DIDObservingTransport.read()` when the first observed `payload.iss`
|
|
8
|
+
* does not match the pinned `expectedServerDID`. The transport enters a
|
|
9
|
+
* permanent failed state: no message bearing the offending `iss` is delivered
|
|
10
|
+
* to the inner Client, and every subsequent `read()` rejects with the same
|
|
11
|
+
* error. In-flight Enkaku RPCs see this error as the `cause` of the Client's
|
|
12
|
+
* "Transport read failed" abort.
|
|
13
|
+
*
|
|
14
|
+
* Distinct from `HubServerDIDConflictError` (raised by manager-level
|
|
15
|
+
* `captureServerDID` when the pin row already differs from the observed DID
|
|
16
|
+
* on the TOFU capture path); this class is raised client-side, before any
|
|
17
|
+
* message is delivered, when a row was already pinned at connect time.
|
|
18
|
+
*/
|
|
19
|
+
export declare class HubServerDIDMismatchError extends Error {
|
|
20
|
+
readonly hubURL: string;
|
|
21
|
+
readonly expectedServerDID: string;
|
|
22
|
+
readonly observedServerDID: string;
|
|
23
|
+
constructor(params: {
|
|
24
|
+
hubURL: string;
|
|
25
|
+
expectedServerDID: string;
|
|
26
|
+
observedServerDID: string;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class ReconnectingError extends Error{hubURL;attempt;constructor(e,r){super(`broadcast wait timed out: hub ${e} reconnecting (attempt ${r})`),this.name="ReconnectingError",this.hubURL=e,this.attempt=r}}export class HubServerDIDMismatchError extends Error{hubURL;expectedServerDID;observedServerDID;constructor(e){super(`hub at ${e.hubURL} signed responses with ${e.observedServerDID}; pinned to ${e.expectedServerDID}`),this.name="HubServerDIDMismatchError",this.hubURL=e.hubURL,this.expectedServerDID=e.expectedServerDID,this.observedServerDID=e.observedServerDID}}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { BroadcastEvent } from '../groups/broadcast-service.js';
|
|
3
|
+
import type { P2PEventEmitter } from '../groups/events.js';
|
|
4
|
+
/**
|
|
5
|
+
* Map a `BroadcastEvent` from `BroadcastService` into the matching
|
|
6
|
+
* `P2PEventEmitter` event.
|
|
7
|
+
*
|
|
8
|
+
* - Events with `applied === false` are ignored (no state change).
|
|
9
|
+
* - `catalog:*` messages are currently a no-op. Catalog send-side broadcasts
|
|
10
|
+
* and event propagation are deferred to a future task (see design spec).
|
|
11
|
+
* - Payloads are built from `event.affected.row`, which `processBroadcast`
|
|
12
|
+
* captures at the appropriate moment (after-state for create / update,
|
|
13
|
+
* before-state for delete / remove).
|
|
14
|
+
*/
|
|
15
|
+
export declare function forwardRemoteBroadcast(emitter: P2PEventEmitter, event: BroadcastEvent, logger?: Logger): Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{toISO as e}from"../context/types.js";export async function forwardRemoteBroadcast(r,t,a){if(!t.applied)return;let{message:i,affected:c}=t;if("delegation:share"!==i.type){if(null==c)return void a?.warn("remote broadcast applied but affected row missing",{type:i.type});switch(i.type){case"circle:create":{if("circle"!==c.kind)return;let{row:t}=c;await r.emit("circleCreated",{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at)});return}case"circle:update":{if("circle"!==c.kind)return;let{row:t}=c,a=null!=i.update.catalogIDs?"circleCatalogsChanged":"circleDataChanged";await r.emit(a,{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at),circleID:t.id});return}case"circle:delete":{if("circle"!==c.kind)return;let{row:t}=c;await r.emit("circleDeleted",{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at)});return}case"member:add":{if("member"!==c.kind)return;let{row:t}=c;await r.emit("circleMemberAdded",{circleID:t.circle_id,memberDID:t.member_did,role:t.role,createdAt:e(t.created_at)});return}case"member:remove":{if("member"!==c.kind)return;let{row:t}=c;await r.emit("circleMemberRemoved",{circleID:t.circle_id,memberDID:t.member_did,role:t.role,createdAt:e(t.created_at)});return}case"group:update":{if("group"!==c.kind)return;let{row:t}=c;await r.emit("groupDataChanged",{id:t.id,name:t.name,description:t.description,createdBy:t.created_by,createdAt:e(t.created_at),groupID:t.id});return}case"catalog:create":case"catalog:update":case"catalog:delete":return void a?.debug("catalog broadcast ignored (not yet wired)",{type:i.type})}}}
|
|
@@ -1,26 +1,9 @@
|
|
|
1
|
+
import { Disposer } from '@enkaku/async';
|
|
1
2
|
import type { Logger } from '@kubun/logger';
|
|
2
3
|
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
3
4
|
import type { GroupBroadcastMessage } from '../groups/broadcast.js';
|
|
4
5
|
import type { BroadcastService } from '../groups/broadcast-service.js';
|
|
5
|
-
import type {
|
|
6
|
-
export type GroupChannelParams = {
|
|
7
|
-
groupID: string;
|
|
8
|
-
hubURL: string;
|
|
9
|
-
deviceID: string;
|
|
10
|
-
pool: HubConnectionPool;
|
|
11
|
-
broadcastService: BroadcastService;
|
|
12
|
-
p2pStore: P2PStoreAPI;
|
|
13
|
-
logger?: Logger;
|
|
14
|
-
ackFlushMs: number;
|
|
15
|
-
ackFlushMax: number;
|
|
16
|
-
backoffBaseMs: number;
|
|
17
|
-
backoffMaxMs: number;
|
|
18
|
-
backoffJitter: number;
|
|
19
|
-
};
|
|
20
|
-
export type Mutex = {
|
|
21
|
-
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
22
|
-
};
|
|
23
|
-
export declare function createMutex(): Mutex;
|
|
6
|
+
import type { HubConnection } from './hub-connection.js';
|
|
24
7
|
export type AckBatchParams = {
|
|
25
8
|
flushMs: number;
|
|
26
9
|
flushMax: number;
|
|
@@ -42,10 +25,43 @@ export declare class AckBatch {
|
|
|
42
25
|
}
|
|
43
26
|
export declare function computeBackoff(attempt: number, base: number, max: number, jitter: number): number;
|
|
44
27
|
export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
45
|
-
export
|
|
28
|
+
export type GroupChannelParams = {
|
|
29
|
+
groupID: string;
|
|
30
|
+
deviceID: string;
|
|
31
|
+
hubConnection: HubConnection;
|
|
32
|
+
broadcastService: BroadcastService;
|
|
33
|
+
p2pStore: P2PStoreAPI;
|
|
34
|
+
logger?: Logger;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Per-group handle: thin wrapper around a shared `HubConnection`.
|
|
38
|
+
*
|
|
39
|
+
* `open()` subscribes to the connection (issuing `hub/group/join` once),
|
|
40
|
+
* `close()` unsubscribes (drains the per-group mutex then issues
|
|
41
|
+
* `hub/group/leave`), and `broadcast()` runs the MLS encrypt+send pipeline
|
|
42
|
+
* inside `HubConnection.runInGroup()` so the per-group mutex serializes send
|
|
43
|
+
* and receive on the same MLS state.
|
|
44
|
+
*
|
|
45
|
+
* All transport state (refID, ack batching, receive loop, reconnect/backoff)
|
|
46
|
+
* lives on `HubConnection` and is shared across every channel for that
|
|
47
|
+
* `hubURL`. A peer with N groups uses ONE `hub/receive` stream per
|
|
48
|
+
* `(deviceDID, hubURL)` rather than N — this preserves the hub-server's
|
|
49
|
+
* single-writer-per-DID invariant for bridge peers.
|
|
50
|
+
*/
|
|
51
|
+
export declare class GroupChannel extends Disposer {
|
|
46
52
|
#private;
|
|
47
53
|
constructor(params: GroupChannelParams);
|
|
48
54
|
open(): Promise<void>;
|
|
49
55
|
close(): Promise<void>;
|
|
50
|
-
broadcast(message: GroupBroadcastMessage
|
|
56
|
+
broadcast(message: GroupBroadcastMessage, opts?: {
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
}): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Fan out a pre-produced MLS handshake Commit to the group's existing
|
|
61
|
+
* members. Mirrors {@link broadcast} but routes through `handleSendCommit`
|
|
62
|
+
* (no application encryption — the Commit is already self-protected).
|
|
63
|
+
*/
|
|
64
|
+
sendCommit(commitBytes: Uint8Array, opts?: {
|
|
65
|
+
timeoutMs?: number;
|
|
66
|
+
}): Promise<void>;
|
|
51
67
|
}
|
package/lib/hub/group-channel.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{Disposer as e}from"@enkaku/async";import{handleReceivedMessage as t}from"./receive-handler.js";import{handleSendBroadcast as r,handleSendCommit as i}from"./send-handler.js";export class AckBatch{#e;#t;#r;#i;#s=new Set;#o=null;constructor(e){this.#e=e.flushMs,this.#t=e.flushMax,this.#r=e.send,this.#i=e.logger}add(e){let t=0===this.#s.size;if(this.#s.add(e),this.#s.size>=this.#t){null!==this.#o&&(clearTimeout(this.#o),this.#o=null),this.flush();return}t&&null===this.#o&&(this.#o=setTimeout(()=>{this.#o=null,this.flush()},this.#e))}async flush(){if(null!==this.#o&&(clearTimeout(this.#o),this.#o=null),0===this.#s.size)return;let e=Array.from(this.#s);this.#s.clear();try{await this.#r(e)}catch(t){this.#i?.error("ack batch send failed",{error:t,count:e.length})}}dispose(){null!==this.#o&&(clearTimeout(this.#o),this.#o=null),this.#s.clear()}}export function computeBackoff(e,t,r,i){let s=Math.min(t*2**e*(1+i*(2*Math.random()-1)),r);return s<0?0:s}export function abortableSleep(e,t){return new Promise((r,i)=>{if(t.aborted)return void i(t.reason??Error("aborted"));let s=setTimeout(()=>{t.removeEventListener("abort",o),r()},e),o=()=>{clearTimeout(s),t.removeEventListener("abort",o),i(t.reason??Error("aborted"))};t.addEventListener("abort",o)})}export class GroupChannel extends e{#n;#h;#a;#u;#c;#i;#d=!1;constructor(e){super({dispose:async()=>{await this.#l()}}),this.#n=e.groupID,this.#h=e.deviceID,this.#a=e.hubConnection,this.#u=e.broadcastService,this.#c=e.p2pStore,this.#i=e.logger}async open(){if(this.#d)throw Error("GroupChannel already opened");let e=await this.#c.getMLSState(this.#n,this.#h);if(null==e)throw Error(`MLS state missing for group ${this.#n}; cannot join hub group`);await this.#a.subscribe(this.#n,e.credential,e=>this.#p(e)),this.#d=!0}#p(e){return t({groupID:this.#n,message:e,broadcastService:this.#u,logger:this.#i})}async close(){this.#d&&await this.dispose()}async #l(){if(this.#d){try{await this.#a.unsubscribe(this.#n)}catch(e){this.#i?.warn("hub-connection unsubscribe failed during close",{groupID:this.#n,error:e})}this.#d=!1}}broadcast(e,t){return this.#d?this.#a.runInGroup(this.#n,async()=>{if(!this.#d)throw Error(`GroupChannel for ${this.#n} is not ready for broadcast`);await r({groupID:this.#n,message:e,broadcastService:this.#u,send:(e,r)=>this.#a.broadcast(e,r,{timeoutMs:t?.timeoutMs}),logger:this.#i})},{timeoutMs:t?.timeoutMs}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for broadcast`))}sendCommit(e,t){return this.#d?this.#a.runInGroup(this.#n,async()=>{if(!this.#d)throw Error(`GroupChannel for ${this.#n} is not ready for sendCommit`);await i({groupID:this.#n,commitBytes:e,broadcastService:this.#u,send:(e,r)=>this.#a.broadcast(e,r,{timeoutMs:t?.timeoutMs}),logger:this.#i})},{timeoutMs:t?.timeoutMs}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for sendCommit`))}}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Identity } from '@enkaku/token';
|
|
2
|
+
import { type ServerDIDObserver } from './did-observing-transport.js';
|
|
3
|
+
import type { CreateHubClient } from './relay-manager.js';
|
|
4
|
+
export type CreateHTTPHubClientParams = {
|
|
5
|
+
identity: Identity;
|
|
6
|
+
fetch?: typeof globalThis.fetch;
|
|
7
|
+
serverID?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Fired once per hub client when the first signed response from the server
|
|
10
|
+
* arrives carrying `payload.iss = serverDID`. Subsequent responses do NOT
|
|
11
|
+
* re-fire this hook, even if `iss` differs. Used by `HubConnectionPool` to
|
|
12
|
+
* drive TOFU pinning into the hub manager. Per-call `onServerDID` from
|
|
13
|
+
* `CreateHubClientOptions` overrides this factory-level default.
|
|
14
|
+
*/
|
|
15
|
+
onServerDID?: ServerDIDObserver;
|
|
16
|
+
};
|
|
17
|
+
export declare function createHTTPHubClient(params: CreateHTTPHubClientParams): CreateHubClient;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{Client as e}from"@enkaku/client";import{ClientTransport as r}from"@enkaku/http-client-transport";import{DIDObservingTransport as t}from"./did-observing-transport.js";export function createHTTPHubClient(n){return async(o,i)=>{let l=new r({url:o,fetch:n.fetch}),u=i?.onServerDID??n.onServerDID,D=i?.expectedServerDID,c=i?.onMismatch;return new e({transport:null!=u||null!=D||null!=c?new t({inner:l,onServerDID:u??(()=>{}),expectedServerDID:D,hubURL:o,onMismatch:c}):l,identity:n.identity,serverID:n.serverID})}}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Disposer } from '@enkaku/async';
|
|
2
|
+
import { EventEmitter } from '@enkaku/event';
|
|
3
|
+
import type { StoredMessage } from '@enkaku/hub-protocol';
|
|
4
|
+
import type { Logger } from '@kubun/logger';
|
|
5
|
+
import type { HubConnectionPool } from './connection-pool.js';
|
|
6
|
+
import type { ReceivedHubMessage } from './receive-handler.js';
|
|
7
|
+
export type TunnelMessageStream = AsyncIterable<StoredMessage> & {
|
|
8
|
+
return(): void;
|
|
9
|
+
};
|
|
10
|
+
export type TunnelMessageStreamParams = {
|
|
11
|
+
peerDID: string;
|
|
12
|
+
};
|
|
13
|
+
export type SubscriptionHandler = (message: ReceivedHubMessage) => Promise<{
|
|
14
|
+
ack: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
export type ConnectionLifecycleEvent = {
|
|
17
|
+
type: 'connected';
|
|
18
|
+
} | {
|
|
19
|
+
type: 'disconnected';
|
|
20
|
+
reason?: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: 'error';
|
|
23
|
+
error: unknown;
|
|
24
|
+
} | {
|
|
25
|
+
type: 'reconnecting';
|
|
26
|
+
attempt: number;
|
|
27
|
+
delayMs: number;
|
|
28
|
+
};
|
|
29
|
+
type HubConnectionEvents = {
|
|
30
|
+
lifecycle: ConnectionLifecycleEvent;
|
|
31
|
+
};
|
|
32
|
+
export type HubConnectionParams = {
|
|
33
|
+
hubURL: string;
|
|
34
|
+
deviceID: string;
|
|
35
|
+
pool: HubConnectionPool;
|
|
36
|
+
logger?: Logger;
|
|
37
|
+
ackFlushMs?: number;
|
|
38
|
+
ackFlushMax?: number;
|
|
39
|
+
backoffBaseMs?: number;
|
|
40
|
+
backoffMaxMs?: number;
|
|
41
|
+
backoffJitter?: number;
|
|
42
|
+
};
|
|
43
|
+
export declare class HubConnection extends Disposer {
|
|
44
|
+
#private;
|
|
45
|
+
constructor(params: HubConnectionParams);
|
|
46
|
+
get events(): EventEmitter<HubConnectionEvents>;
|
|
47
|
+
open(): Promise<void>;
|
|
48
|
+
subscribe(groupID: string, credential: string, handler: SubscriptionHandler): Promise<void>;
|
|
49
|
+
unsubscribe(groupID: string): Promise<void>;
|
|
50
|
+
broadcast(groupID: string, encryptedPayload: string, opts?: {
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
}): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Send an opaque tunnel-style message to explicit recipients via
|
|
55
|
+
* `hub/send`. No `groupID` is attached at the hub level — recipients
|
|
56
|
+
* receive the message on the tunnel-routing path (see
|
|
57
|
+
* {@link tunnelMessageStream}). Used by hub-mediated document sync.
|
|
58
|
+
*/
|
|
59
|
+
send(params: {
|
|
60
|
+
recipients: Array<string>;
|
|
61
|
+
payload: Uint8Array;
|
|
62
|
+
timeoutMs?: number;
|
|
63
|
+
}): Promise<{
|
|
64
|
+
sequenceID: string;
|
|
65
|
+
}>;
|
|
66
|
+
/**
|
|
67
|
+
* Run `fn` inside the same per-group mutex used by receive `#dispatch`. This
|
|
68
|
+
* lets callers (e.g. `GroupChannel.broadcast`) serialize MLS-state-mutating
|
|
69
|
+
* pipelines with incoming-message handling on the same group, avoiding races
|
|
70
|
+
* between concurrent send-encrypt and receive-decrypt against shared MLS
|
|
71
|
+
* group state.
|
|
72
|
+
*
|
|
73
|
+
* The gate-await ensures the subscription map is settled before lookup —
|
|
74
|
+
* i.e. mid-reconnect callers wait for the subscription to be (re)registered
|
|
75
|
+
* rather than throwing.
|
|
76
|
+
*/
|
|
77
|
+
runInGroup<T>(groupID: string, fn: () => Promise<T>, opts?: {
|
|
78
|
+
timeoutMs?: number;
|
|
79
|
+
}): Promise<T>;
|
|
80
|
+
/**
|
|
81
|
+
* Register a per-peer tunnel inbox and return a disposable async iterable
|
|
82
|
+
* of {@link StoredMessage} values received from that peer over the tunnel
|
|
83
|
+
* (i.e. direct hub messages with no `groupID`).
|
|
84
|
+
*
|
|
85
|
+
* Used by the hub-mediated document sync server adapter (see Q3 of the
|
|
86
|
+
* hub-mediated document sync plan). The hub's receive-side dispatcher
|
|
87
|
+
* keys tunnel routing on `senderDID` because hub-level messages cannot
|
|
88
|
+
* reveal the in-frame `sessionID` (the body is encrypted). P2P pairs
|
|
89
|
+
* `peerDID` and `sessionID` at session creation, so peer-keying is
|
|
90
|
+
* sufficient. Concurrent tunnel sessions to the same peer are not
|
|
91
|
+
* supported and are the caller's responsibility to avoid.
|
|
92
|
+
*/
|
|
93
|
+
tunnelMessageStream({ peerDID }: TunnelMessageStreamParams): TunnelMessageStream;
|
|
94
|
+
close(): Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{Disposer as e}from"@enkaku/async";import{fromB64 as t,toB64 as s}from"@enkaku/codec";import{EventEmitter as n}from"@enkaku/event";import{createMutex as i}from"../util/mutex.js";import{ReconnectingError as r}from"./errors.js";import{AckBatch as o,abortableSleep as h,computeBackoff as a}from"./group-channel.js";import{TunnelInbox as c}from"./tunnel-inbox.js";import{waitForGate as l}from"./wait-for-gate.js";let u=Symbol("disposed");export class HubConnection extends e{#e;#t;#s;#n;#i;#r;#o;#h;#a;#c=null;#l=null;#u=null;#b=null;#p=null;#d=new Map;#f=!1;#g=0;#m=null;#y=new Map;#w;constructor(e){super({dispose:async()=>{await this.#L()}}),this.#e=e.hubURL,this.#t=e.deviceID,this.#s=e.pool,this.#n=e.logger,this.#i=e.ackFlushMs??500,this.#r=e.ackFlushMax??10,this.#o=e.backoffBaseMs??1e3,this.#h=e.backoffMaxMs??6e4,this.#a=e.backoffJitter??.25,this.#w=new n}get events(){return this.#w}async #I(e){try{await this.#w.emit("lifecycle",e)}catch(t){this.#n?.warn("hub-connection lifecycle subscriber threw",{hubURL:this.#e,type:e.type,error:t})}}async open(){if(this.#f)throw Error("HubConnection already opened");try{await this.#k(),this.#f=!0,await this.#I({type:"connected"})}catch(e){if(null!=this.#l){try{await this.#s.release(this.#e,this.#l)}catch(e){this.#n?.debug("hub-connection pool release after open failure failed",{hubURL:this.#e,error:e})}this.#l=null}throw this.#c=null,e}}async #k(){if(null!=this.#l){let e=this.#l;this.#l=null,this.#c=null;try{await this.#s.release(this.#e,e)}catch(e){this.#n?.debug("hub-connection pool release before reconnect failed",{hubURL:this.#e,error:e})}}let{client:e,refID:t}=await this.#s.acquire(this.#e);this.#c=e,this.#l=t;let s=e.createChannel("hub/receive",{param:{},signal:this.signal});this.#u=s,s.catch(()=>{}),this.#b=new o({flushMs:this.#i,flushMax:this.#r,send:async e=>{await s.send({ack:e})},logger:this.#n}),this.#p=this.#R(e).catch(e=>{this.#n?.debug("hub-connection receive task tail error",{hubURL:this.#e,error:e})});let n=[...this.#d.entries()];if(n.length>0){let t=await Promise.allSettled(n.map(async([t,s])=>{await e.request("hub/group/join",{param:{groupID:t,credential:s.credential}})}));for(let e=0;e<t.length;e++){let s=t[e];if("rejected"===s.status){let t=n[e][0];this.#n?.warn("hub-connection rejoin failed",{hubURL:this.#e,groupID:t,error:s.reason})}}}}async subscribe(e,t,s){if(await l(this.#m,void 0),!this.#f||null==this.#c)throw Error("HubConnection not open");if(this.#d.has(e))throw Error(`already subscribed to group ${e}`);await this.#c.request("hub/group/join",{param:{groupID:e,credential:t}}),this.#d.set(e,{credential:t,handler:s,mutex:i()})}async unsubscribe(e){await l(this.#m,void 0);let t=this.#c;if(!this.#f||null==t)throw Error("HubConnection not open");let s=this.#d.get(e);if(null==s)throw Error(`not subscribed to group ${e}`);await s.mutex.run(async()=>{}),await t.request("hub/group/leave",{param:{groupID:e}}),this.#d.delete(e)}async broadcast(e,t,s){if(await l(this.#m,s?.timeoutMs,()=>new r(this.#e,this.#g)),!this.#f||null==this.#c)throw Error("HubConnection not open");await this.#c.request("hub/group/send",{param:{groupID:e,payload:t}})}async send(e){if(await l(this.#m,e.timeoutMs,()=>new r(this.#e,this.#g)),!this.#f||null==this.#c)throw Error("HubConnection not open");return{sequenceID:(await this.#c.request("hub/send",{param:{recipients:e.recipients,payload:s(e.payload)}})).sequenceID}}async runInGroup(e,t,s){if(await l(this.#m,s?.timeoutMs,()=>new r(this.#e,this.#g)),!this.#f)throw Error("HubConnection not open");let n=this.#d.get(e);if(null==n)throw Error(`not subscribed to group ${e}`);return n.mutex.run(t)}tunnelMessageStream({peerDID:e}){if(this.#y.has(e))throw Error("tunnelMessageStream: peerDID already registered");let t=new c,s={inbox:t};this.#y.set(e,s);let n=!1;return{[Symbol.asyncIterator]:()=>t.iterator(),return:()=>{n||(n=!0,this.#y.get(e)===s&&this.#y.delete(e),t.close())}}}async #R(e){let t=this.#u;if(null==t)return;let s=t.readable.getReader(),n=null;try{for(;!this.signal.aborted;){let t=s.read(),n=e.disposed.then(()=>u),i=await Promise.race([t,n]);if("symbol"==typeof i){this.#n?.debug("hub-connection receive loop observed hub client disposal",{hubURL:this.#e});break}let{done:r,value:o}=i;if(r)break;this.#D(o),this.#g=0}}catch(e){n=e,this.#n?.warn("hub-connection receive loop error",{hubURL:this.#e,error:e})}finally{try{s.releaseLock()}catch{}}if(!this.signal.aborted){let e=n instanceof Error&&n.message?n.message:"receive loop ended";await this.#I({type:"disconnected",reason:e}),this.#U()}}#U(){this.#p=this.#M().catch(e=>{this.#n?.debug("hub-connection reconnect task tail error",{hubURL:this.#e,error:e})})}async #M(){let e=()=>{};this.#m=new Promise(t=>{e=t});try{for(;!this.signal.aborted;){let e=a(this.#g++,this.#o,this.#h,this.#a);this.#n?.debug("hub-connection reconnect scheduled",{hubURL:this.#e,delay:e,attempt:this.#g});try{await h(e,this.signal)}catch{return}await this.#I({type:"reconnecting",attempt:this.#g,delayMs:e});try{this.#b?.dispose(),this.#b=null,this.#u=null,await this.#k(),this.#g=0,await this.#I({type:"connected"});return}catch(e){this.#n?.warn("hub-connection reconnect attempt failed",{hubURL:this.#e,attempt:this.#g,error:e}),await this.#I({type:"error",error:e})}}}finally{this.#m=null,e()}}#D(e){if(null==e.groupID){let s=this.#y.get(e.senderDID);if(null!=s){let n={sequenceID:e.sequenceID,senderDID:e.senderDID,payload:t(e.payload)};s.inbox.push(n),this.#b?.add(e.sequenceID);return}this.#n?.debug("direct message received, no handler",{hubURL:this.#e,sequenceID:e.sequenceID}),this.#b?.add(e.sequenceID);return}let s=this.#d.get(e.groupID);null==s?this.#n?.debug("message for unsubscribed group",{hubURL:this.#e,groupID:e.groupID,sequenceID:e.sequenceID}):s.mutex.run(()=>s.handler(e)).then(t=>{t.ack&&this.#b?.add(e.sequenceID)}).catch(t=>{this.#n?.warn("hub-connection subscription handler error",{hubURL:this.#e,groupID:e.groupID,sequenceID:e.sequenceID,error:t})})}async close(){this.#f&&await this.dispose()}async #L(){if(!this.#f)return;let e=this.#b;if(this.#b=null,null!=e){try{await e.flush()}catch(e){this.#n?.debug("hub-connection ack flush during close failed",{hubURL:this.#e,error:e})}e.dispose()}try{this.#u?.close()}catch{}try{await this.#p}catch{}let t=[...this.#d.values()].map(e=>e.mutex.run(async()=>{}).catch(()=>{}));if(await Promise.allSettled(t),null!=this.#l){try{await this.#s.release(this.#e,this.#l)}catch(e){this.#n?.debug("hub-connection pool release during close failed",{hubURL:this.#e,error:e})}this.#l=null}for(let e of(this.#c=null,this.#u=null,this.#p=null,this.#d.clear(),this.#y.values()))e.inbox.close();this.#y.clear(),this.#f=!1}}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { StoreProvider } from '@kubun/db';
|
|
2
|
+
export type Hub = {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string | null;
|
|
5
|
+
url: string;
|
|
6
|
+
serverDID: string | null;
|
|
7
|
+
createdAt: Date;
|
|
8
|
+
updatedAt: Date | null;
|
|
9
|
+
};
|
|
10
|
+
export type CreateHubParams = {
|
|
11
|
+
stores: StoreProvider;
|
|
12
|
+
url: string;
|
|
13
|
+
label?: string | null;
|
|
14
|
+
serverDID?: string | null;
|
|
15
|
+
};
|
|
16
|
+
export type UpdateHubParams = {
|
|
17
|
+
stores: StoreProvider;
|
|
18
|
+
id: string;
|
|
19
|
+
label?: string | null;
|
|
20
|
+
url?: string;
|
|
21
|
+
serverDID?: string | null;
|
|
22
|
+
};
|
|
23
|
+
export type DeleteHubParams = {
|
|
24
|
+
stores: StoreProvider;
|
|
25
|
+
id: string;
|
|
26
|
+
force?: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type DeleteHubResult = {
|
|
29
|
+
success: boolean;
|
|
30
|
+
boundGroupIDs: Array<string>;
|
|
31
|
+
};
|
|
32
|
+
export type GetHubParams = {
|
|
33
|
+
stores: StoreProvider;
|
|
34
|
+
id: string;
|
|
35
|
+
};
|
|
36
|
+
export type ListHubsParams = {
|
|
37
|
+
stores: StoreProvider;
|
|
38
|
+
};
|
|
39
|
+
export type BindHubToGroupParams = {
|
|
40
|
+
stores: StoreProvider;
|
|
41
|
+
hubID: string;
|
|
42
|
+
groupID: string;
|
|
43
|
+
};
|
|
44
|
+
export type UnbindHubFromGroupParams = {
|
|
45
|
+
stores: StoreProvider;
|
|
46
|
+
hubID: string;
|
|
47
|
+
groupID: string;
|
|
48
|
+
};
|
|
49
|
+
export type ListHubsByGroupParams = {
|
|
50
|
+
stores: StoreProvider;
|
|
51
|
+
groupID: string;
|
|
52
|
+
};
|
|
53
|
+
export type ListGroupsByHubParams = {
|
|
54
|
+
stores: StoreProvider;
|
|
55
|
+
hubID: string;
|
|
56
|
+
};
|
|
57
|
+
export type CaptureServerDIDParams = {
|
|
58
|
+
stores: StoreProvider;
|
|
59
|
+
hubURL: string;
|
|
60
|
+
serverDID: string;
|
|
61
|
+
};
|
|
62
|
+
export type UpsertHubParams = {
|
|
63
|
+
stores: StoreProvider;
|
|
64
|
+
url: string;
|
|
65
|
+
serverDID?: string | null;
|
|
66
|
+
};
|
|
67
|
+
export declare class HubServerDIDConflictError extends Error {
|
|
68
|
+
readonly hubID: string;
|
|
69
|
+
readonly url: string;
|
|
70
|
+
readonly existingServerDID: string;
|
|
71
|
+
readonly providedServerDID: string;
|
|
72
|
+
constructor(params: {
|
|
73
|
+
hubID: string;
|
|
74
|
+
url: string;
|
|
75
|
+
existingServerDID: string;
|
|
76
|
+
providedServerDID: string;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export declare function createHub(params: CreateHubParams): Promise<Hub>;
|
|
80
|
+
/**
|
|
81
|
+
* Idempotent register-or-pin entry-point. Applies the conflict matrix:
|
|
82
|
+
*
|
|
83
|
+
* - URL not registered → insert a fresh row with `serverDID` (or null) and
|
|
84
|
+
* return it.
|
|
85
|
+
* - existing row, `server_did === null`, provided null → no-op.
|
|
86
|
+
* - existing row, `server_did === null`, provided DID-A → upgrade pin to
|
|
87
|
+
* DID-A.
|
|
88
|
+
* - existing row, `server_did === DID-A`, provided null or matching DID-A →
|
|
89
|
+
* no-op (no silent downgrade).
|
|
90
|
+
* - existing row, `server_did === DID-A`, provided DID-B → throws
|
|
91
|
+
* `HubServerDIDConflictError`. Caller must explicit `updateHub` to change
|
|
92
|
+
* a pinned DID.
|
|
93
|
+
*
|
|
94
|
+
* Unlike `createHub`, `upsertHub` takes no `label` — it is for atomic
|
|
95
|
+
* register+bind paths where the inviter only ships connectivity + pin.
|
|
96
|
+
*/
|
|
97
|
+
export declare function upsertHub(params: UpsertHubParams): Promise<Hub>;
|
|
98
|
+
export declare function updateHub(params: UpdateHubParams): Promise<Hub>;
|
|
99
|
+
export declare function deleteHub(params: DeleteHubParams): Promise<DeleteHubResult>;
|
|
100
|
+
export declare function getHub(params: GetHubParams): Promise<Hub | null>;
|
|
101
|
+
export declare function listHubs(params: ListHubsParams): Promise<Array<Hub>>;
|
|
102
|
+
export declare function bindHubToGroup(params: BindHubToGroupParams): Promise<boolean>;
|
|
103
|
+
export declare function unbindHubFromGroup(params: UnbindHubFromGroupParams): Promise<boolean>;
|
|
104
|
+
export declare function listHubsByGroup(params: ListHubsByGroupParams): Promise<Array<Hub>>;
|
|
105
|
+
export declare function listGroupsByHub(params: ListGroupsByHubParams): Promise<Array<string>>;
|
|
106
|
+
/**
|
|
107
|
+
* TOFU capture path. Looks up the local hub row by URL and applies the
|
|
108
|
+
* conflict matrix:
|
|
109
|
+
* - URL not registered → no-op, returns `false`.
|
|
110
|
+
* - existing `server_did` is null → upgrade to `serverDID`, returns `true`.
|
|
111
|
+
* - existing `server_did` matches `serverDID` → no-op, returns `false`.
|
|
112
|
+
* - existing `server_did` differs from `serverDID` → throws
|
|
113
|
+
* `HubServerDIDConflictError`. Pinned-mismatch enforcement (hard-fail
|
|
114
|
+
* before any message is delivered) lives in the transport wrapper and
|
|
115
|
+
* surfaces `HubServerDIDMismatchError` to callers.
|
|
116
|
+
*/
|
|
117
|
+
export declare function captureServerDID(params: CaptureServerDIDParams): Promise<boolean>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{getP2PStore as r}from"@kubun/store-p2p";export class HubServerDIDConflictError extends Error{hubID;url;existingServerDID;providedServerDID;constructor(r){super(`hub at ${r.url} is pinned to ${r.existingServerDID}; refusing to overwrite with ${r.providedServerDID}`),this.name="HubServerDIDConflictError",this.hubID=r.hubID,this.url=r.url,this.existingServerDID=r.existingServerDID,this.providedServerDID=r.providedServerDID}}function e(r){return{id:r.id,label:r.label,url:r.url,serverDID:r.server_did,createdAt:new Date(r.created_at),updatedAt:null==r.updated_at?null:new Date(r.updated_at)}}export async function createHub(t){let u=await r(t.stores),i=t.serverDID??null,a=await u.getHubByURL(t.url);if(null!=a){if(null!=a.server_did&&null!=i&&a.server_did!==i)throw new HubServerDIDConflictError({hubID:a.id,url:t.url,existingServerDID:a.server_did,providedServerDID:i});if(null==a.server_did&&null!=i){await u.updateHub(a.id,{server_did:i});let r=await u.getHub(a.id);if(null==r)throw Error("Failed to read hub after upgrade");return e(r)}return e(a)}let l=crypto.randomUUID();await u.insertHub({id:l,label:t.label??null,url:t.url,server_did:i});let n=await u.getHub(l);if(null==n)throw Error("Failed to read hub after insert");return e(n)}export async function upsertHub(r){return await createHub({stores:r.stores,url:r.url,serverDID:r.serverDID})}export async function updateHub(t){let u=await r(t.stores),i=await u.getHub(t.id);if(null==i)throw Error(`Hub ${t.id} not found`);if(null!=t.url&&t.url!==i.url){let r=await u.getHubByURL(t.url);if(null!=r&&r.id!==t.id)throw Error(`URL ${t.url} already in use by hub ${r.id}`)}let a={};void 0!==t.label&&(a.label=t.label),void 0!==t.url&&(a.url=t.url),void 0!==t.serverDID&&(a.server_did=t.serverDID),await u.updateHub(t.id,a);let l=await u.getHub(t.id);if(null==l)throw Error("Failed to read hub after update");return e(l)}export async function deleteHub(e){let t=await r(e.stores),u=await t.listGroupIDsByHubID(e.id);return u.length>0&&!e.force?{success:!1,boundGroupIDs:u}:(u.length>0&&await t.deleteGroupHubsByHubID(e.id),await t.deleteHub(e.id),{success:!0,boundGroupIDs:u})}export async function getHub(t){let u=await r(t.stores),i=await u.getHub(t.id);return null==i?null:e(i)}export async function listHubs(t){let u=await r(t.stores);return(await u.listHubs()).map(e)}export async function bindHubToGroup(e){let t=await r(e.stores);return await t.insertGroupHub({group_id:e.groupID,hub_id:e.hubID})}export async function unbindHubFromGroup(e){let t=await r(e.stores);return await t.deleteGroupHub({groupID:e.groupID,hubID:e.hubID})}export async function listHubsByGroup(t){let u=await r(t.stores);return(await u.listHubsByGroupID(t.groupID)).map(e)}export async function listGroupsByHub(e){let t=await r(e.stores);return await t.listGroupIDsByHubID(e.hubID)}export async function captureServerDID(e){let t=await r(e.stores),u=await t.getHubByURL(e.hubURL);if(null==u)return!1;if(null==u.server_did)return await t.updateHub(u.id,{server_did:e.serverDID}),!0;if(u.server_did===e.serverDID)return!1;throw new HubServerDIDConflictError({hubID:u.id,url:e.hubURL,existingServerDID:u.server_did,providedServerDID:e.serverDID})}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Logger } from '@kubun/logger';
|
|
2
|
-
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
3
2
|
import { type BroadcastService } from '../groups/broadcast-service.js';
|
|
4
3
|
/**
|
|
5
4
|
* Shape of a message pushed by `hub/receive`. Matches `@enkaku/hub-protocol`
|
|
@@ -14,9 +13,7 @@ export type ReceivedHubMessage = {
|
|
|
14
13
|
};
|
|
15
14
|
export type HandleReceivedMessageParams = {
|
|
16
15
|
groupID: string;
|
|
17
|
-
deviceID: string;
|
|
18
16
|
message: ReceivedHubMessage;
|
|
19
|
-
p2pStore: P2PStoreAPI;
|
|
20
17
|
broadcastService: BroadcastService;
|
|
21
18
|
logger?: Logger;
|
|
22
19
|
};
|
|
@@ -29,8 +26,8 @@ export type HandleReceivedMessageResult = {
|
|
|
29
26
|
* Returns `{ ack: true }` when the message has been processed to a state where
|
|
30
27
|
* redelivery serves no purpose (applied successfully, or decrypt failed with
|
|
31
28
|
* no recovery path). Returns `{ ack: false }` when the hub should redeliver
|
|
32
|
-
* (transient apply or
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* (transient apply failure or unexpected error). Skips with `{ ack: false }`
|
|
30
|
+
* on groupID mismatch so the message stays queued for whichever recipient it
|
|
31
|
+
* was intended for.
|
|
35
32
|
*/
|
|
36
33
|
export declare function handleReceivedMessage(params: HandleReceivedMessageParams): Promise<HandleReceivedMessageResult>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{fromB64 as e}from"@enkaku/codec";import{ApplyError as r,
|
|
1
|
+
import{fromB64 as e}from"@enkaku/codec";import{ApplyError as r,CommitOutOfOrderError as c,DecryptError as s}from"../groups/broadcast-service.js";export async function handleReceivedMessage(a){let{groupID:n,message:o,broadcastService:i,logger:t}=a;if(o.groupID!==n)return t?.debug("hub message groupID mismatch, skipping",{groupID:n,messageGroupID:o.groupID,sequenceID:o.sequenceID}),{ack:!1};let u=e(o.payload);try{await i.processReceived({groupID:n,encrypted:u})}catch(e){if(e instanceof s)return t?.debug("decrypt failed, acking and skipping",{groupID:n,sequenceID:o.sequenceID,error:e}),{ack:!0};if(e instanceof c)return t?.debug("mls commit out of order, will redeliver after gap fills",{groupID:n,sequenceID:o.sequenceID,messageEpoch:e.messageEpoch.toString(),handleEpoch:e.handleEpoch.toString()}),{ack:!1};if(e instanceof r)return t?.error("broadcast apply failed, will redeliver",{groupID:n,sequenceID:o.sequenceID,error:e}),{ack:!1};return t?.error("unexpected error processing hub message",{groupID:n,sequenceID:o.sequenceID,error:e}),{ack:!1}}return{ack:!0}}
|
|
@@ -1,12 +1,65 @@
|
|
|
1
1
|
import type { Client } from '@enkaku/client';
|
|
2
|
+
import { EventEmitter } from '@enkaku/event';
|
|
2
3
|
import type { HubProtocol } from '@enkaku/hub-protocol';
|
|
3
4
|
import type { Logger } from '@kubun/logger';
|
|
4
5
|
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
5
6
|
import type { GroupBroadcastMessage } from '../groups/broadcast.js';
|
|
6
7
|
import type { BroadcastService } from '../groups/broadcast-service.js';
|
|
8
|
+
import { type OnServerDIDObserved } from './connection-pool.js';
|
|
9
|
+
import type { ServerDIDMismatchObserver, ServerDIDObserver } from './did-observing-transport.js';
|
|
7
10
|
import { GroupChannel, type GroupChannelParams } from './group-channel.js';
|
|
8
|
-
|
|
11
|
+
import { HubConnection, type HubConnectionParams } from './hub-connection.js';
|
|
12
|
+
export type CreateHubClientOptions = {
|
|
13
|
+
/**
|
|
14
|
+
* Per-call observer fired once when the first signed response arrives. Set
|
|
15
|
+
* by `HubConnectionPool` so the pool can route the observed server DID
|
|
16
|
+
* through its `onServerDIDObserved` handler with the hub URL bound. Factories
|
|
17
|
+
* that don't honour this opt simply skip TOFU capture for pool-spawned
|
|
18
|
+
* clients.
|
|
19
|
+
*/
|
|
20
|
+
onServerDID?: ServerDIDObserver;
|
|
21
|
+
/**
|
|
22
|
+
* Pinned `server_did` resolved by the pool from the local hub row at spawn
|
|
23
|
+
* time. When set, the underlying `DIDObservingTransport` enforces it: a
|
|
24
|
+
* non-matching first response raises `HubServerDIDMismatchError` and no
|
|
25
|
+
* message is delivered to the inner Client (hard-fail path). When unset,
|
|
26
|
+
* the wrapper falls back to TOFU capture via `onServerDID`.
|
|
27
|
+
*/
|
|
28
|
+
expectedServerDID?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Side-channel notifier fired when a pinned-DID mismatch is detected. Lets
|
|
31
|
+
* the pool log + evict on top of the typed error that propagates through
|
|
32
|
+
* the read loop.
|
|
33
|
+
*/
|
|
34
|
+
onMismatch?: ServerDIDMismatchObserver;
|
|
35
|
+
};
|
|
36
|
+
export type CreateHubClient = (hubURL: string, opts?: CreateHubClientOptions) => Promise<Client<HubProtocol>>;
|
|
9
37
|
export type CreateGroupChannel = (params: GroupChannelParams) => GroupChannel;
|
|
38
|
+
export type CreateHubConnection = (params: HubConnectionParams) => HubConnection;
|
|
39
|
+
export type RelayEvent = {
|
|
40
|
+
type: 'connected';
|
|
41
|
+
hubURL: string;
|
|
42
|
+
groupIDs: Array<string>;
|
|
43
|
+
} | {
|
|
44
|
+
type: 'disconnected';
|
|
45
|
+
hubURL: string;
|
|
46
|
+
groupIDs: Array<string>;
|
|
47
|
+
reason?: string;
|
|
48
|
+
} | {
|
|
49
|
+
type: 'error';
|
|
50
|
+
hubURL: string;
|
|
51
|
+
groupIDs: Array<string>;
|
|
52
|
+
error: unknown;
|
|
53
|
+
} | {
|
|
54
|
+
type: 'reconnecting';
|
|
55
|
+
hubURL: string;
|
|
56
|
+
groupIDs: Array<string>;
|
|
57
|
+
attempt: number;
|
|
58
|
+
delayMs: number;
|
|
59
|
+
};
|
|
60
|
+
type RelayManagerEvents = {
|
|
61
|
+
relay: RelayEvent;
|
|
62
|
+
};
|
|
10
63
|
export type HubRelayManagerParams = {
|
|
11
64
|
p2pStore: P2PStoreAPI;
|
|
12
65
|
broadcastService: BroadcastService;
|
|
@@ -18,10 +71,21 @@ export type HubRelayManagerParams = {
|
|
|
18
71
|
backoffBaseMs?: number;
|
|
19
72
|
backoffMaxMs?: number;
|
|
20
73
|
backoffJitter?: number;
|
|
74
|
+
broadcastTimeoutMs?: number;
|
|
21
75
|
createGroupChannel?: CreateGroupChannel;
|
|
76
|
+
createHubConnection?: CreateHubConnection;
|
|
77
|
+
/**
|
|
78
|
+
* Forwarded to the underlying `HubConnectionPool`. Fired with the observed
|
|
79
|
+
* `serverDID` after the first signed response from a freshly-spawned client.
|
|
80
|
+
* Used by the hub manager to apply the TOFU capture conflict matrix (the
|
|
81
|
+
* `null → DID-A` upgrade). Errors are caught and logged by the pool;
|
|
82
|
+
* pinned-mismatch enforcement is a separate path inside the wrapper.
|
|
83
|
+
*/
|
|
84
|
+
onServerDIDObserved?: OnServerDIDObserved;
|
|
22
85
|
};
|
|
23
86
|
export declare class HubRelayManager {
|
|
24
87
|
#private;
|
|
88
|
+
readonly relayEvents: EventEmitter<RelayManagerEvents>;
|
|
25
89
|
constructor(params: HubRelayManagerParams);
|
|
26
90
|
/**
|
|
27
91
|
* Open channels for each group in parallel. Per-group failures are logged
|
|
@@ -31,7 +95,33 @@ export declare class HubRelayManager {
|
|
|
31
95
|
*/
|
|
32
96
|
start(groupIDs: Array<string>): Promise<void>;
|
|
33
97
|
addGroup(groupID: string): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Post-join binding-add hook. Single-hub regime — when the group already
|
|
100
|
+
* has an active channel:
|
|
101
|
+
* - same hubURL → debug log, no-op (idempotent re-bind);
|
|
102
|
+
* - different hubURL → warn, no-op (extra binding deferred until
|
|
103
|
+
* multi-hub support lands).
|
|
104
|
+
* When the group has no active channel yet (e.g. binding was created
|
|
105
|
+
* before relay setup ran), open one for the supplied hub.
|
|
106
|
+
*/
|
|
107
|
+
addBinding(groupID: string, hubURL: string): Promise<void>;
|
|
108
|
+
/**
|
|
109
|
+
* Post-join binding-remove hook. No-op when the unbound hub is not the
|
|
110
|
+
* group's active hub (single-hub regime: unbinding a non-active hub leaves
|
|
111
|
+
* the channel intact). When it matches, behaves like `removeGroup`.
|
|
112
|
+
*/
|
|
113
|
+
removeBinding(groupID: string, hubURL: string): Promise<void>;
|
|
34
114
|
removeGroup(groupID: string): Promise<void>;
|
|
35
|
-
broadcast(groupID: string, message: GroupBroadcastMessage
|
|
115
|
+
broadcast(groupID: string, message: GroupBroadcastMessage, opts?: {
|
|
116
|
+
timeoutMs?: number;
|
|
117
|
+
}): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* Fan out a pre-produced MLS handshake Commit to a group's existing members
|
|
120
|
+
* via its active channel. Mirrors {@link broadcast}.
|
|
121
|
+
*/
|
|
122
|
+
sendCommit(groupID: string, commitBytes: Uint8Array, opts?: {
|
|
123
|
+
timeoutMs?: number;
|
|
124
|
+
}): Promise<void>;
|
|
36
125
|
stop(): Promise<void>;
|
|
37
126
|
}
|
|
127
|
+
export {};
|