@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,90 @@
|
|
|
1
|
+
import type { GroupHandle } from '@enkaku/group';
|
|
2
|
+
import type { StoreProvider } from '@kubun/db';
|
|
3
|
+
import { type Logger } from '@kubun/logger';
|
|
4
|
+
export type GroupHandleRegistryParams = {
|
|
5
|
+
stores: StoreProvider;
|
|
6
|
+
deviceID: string;
|
|
7
|
+
logger?: Logger;
|
|
8
|
+
};
|
|
9
|
+
export type SeedParams = {
|
|
10
|
+
groupID: string;
|
|
11
|
+
handle: GroupHandle;
|
|
12
|
+
/**
|
|
13
|
+
* Pass the transactional `StoreProvider` (the `tx` from
|
|
14
|
+
* `stores.withTransaction(async (tx) => ...)`) so the initial MLS row
|
|
15
|
+
* write rolls back with the rest of the create/join flow on transaction
|
|
16
|
+
* abort. The cache install is deferred to `stores.onCommit` so a rollback
|
|
17
|
+
* leaves the cache empty.
|
|
18
|
+
*/
|
|
19
|
+
stores: StoreProvider;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Single canonical access point for a device's MLS `GroupHandle` instances.
|
|
23
|
+
*
|
|
24
|
+
* Owns the per-group async mutex covering encrypt/decrypt and epoch-bumping
|
|
25
|
+
* operations, and persists the (possibly mutated) handle on every successful
|
|
26
|
+
* `withHandle` callback. On callback throw, persist is skipped; the in-memory
|
|
27
|
+
* handle retains its mutated state so the caller can decide whether to retry.
|
|
28
|
+
*
|
|
29
|
+
* All five mutation paths (manager member ops, context handlers, broadcast
|
|
30
|
+
* service encrypt/decrypt, hub-tunnel sync listener, hub-tunnel sync provider)
|
|
31
|
+
* route MLS state through this registry. Spec:
|
|
32
|
+
* `docs/superpowers/specs/2026-05-09-group-handle-registry-design.md`.
|
|
33
|
+
*/
|
|
34
|
+
export declare class GroupHandleRegistry {
|
|
35
|
+
#private;
|
|
36
|
+
constructor(params: GroupHandleRegistryParams);
|
|
37
|
+
/**
|
|
38
|
+
* Acquire the canonical handle for `groupID`, run `fn` under the per-group
|
|
39
|
+
* mutex, persist the (possibly mutated) handle on success, release.
|
|
40
|
+
* On throw, persist is skipped; in-memory handle retains its mutated state.
|
|
41
|
+
*
|
|
42
|
+
* Use this for in-place mutating ops (encrypt/decrypt) and reads. For
|
|
43
|
+
* epoch-bumping ops that produce a fresh `GroupHandle` (commitInvite,
|
|
44
|
+
* removeMember, processCommit), use `withHandleReplacing` instead.
|
|
45
|
+
*
|
|
46
|
+
* `options.stores` overrides the constructor-stored `StoreProvider` for the
|
|
47
|
+
* restore + persist DB calls. Callers running inside an engine transaction
|
|
48
|
+
* (e.g. context handlers under `mutateGraph`) MUST pass the transactional
|
|
49
|
+
* `tx` so reads + writes share the tx's connection. Without this, SQLite's
|
|
50
|
+
* write lock held by the outer tx blocks the registry's persist forever.
|
|
51
|
+
*/
|
|
52
|
+
withHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T>, options?: {
|
|
53
|
+
stores?: StoreProvider;
|
|
54
|
+
}): Promise<T>;
|
|
55
|
+
/**
|
|
56
|
+
* Like `withHandle`, but the callback returns `{ result, updated }`.
|
|
57
|
+
* Registry replaces its cached handle with `updated` before persisting on
|
|
58
|
+
* success. Required for `@enkaku/group` epoch ops (`commitInvite`,
|
|
59
|
+
* `removeMember`, `processCommit`) which construct a fresh `GroupHandle`
|
|
60
|
+
* rather than mutating the input.
|
|
61
|
+
*
|
|
62
|
+
* `options.stores` follows the same semantics as `withHandle.options.stores`.
|
|
63
|
+
*/
|
|
64
|
+
withHandleReplacing<T>(groupID: string, fn: (handle: GroupHandle) => Promise<{
|
|
65
|
+
result: T;
|
|
66
|
+
updated: GroupHandle;
|
|
67
|
+
}>, options?: {
|
|
68
|
+
stores?: StoreProvider;
|
|
69
|
+
}): Promise<T>;
|
|
70
|
+
/**
|
|
71
|
+
* Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
|
|
72
|
+
* Called by `leaveGroup` after the MLS state row is deleted, and by tests.
|
|
73
|
+
*/
|
|
74
|
+
invalidate(groupID: string): void;
|
|
75
|
+
/**
|
|
76
|
+
* Prime the cache with a freshly-built handle and persist the initial row
|
|
77
|
+
* via the supplied transactional `StoreProvider`. Called by `createGroup`
|
|
78
|
+
* and `joinGroup` so the row write rolls back with the rest of the
|
|
79
|
+
* create/join transaction on `withTransaction` abort. The cache install is
|
|
80
|
+
* deferred to `stores.onCommit` so a rollback leaves the cache empty.
|
|
81
|
+
*
|
|
82
|
+
* Must NOT be called concurrently with `withHandle` / `withHandleReplacing`
|
|
83
|
+
* on the same `groupID`: the `seed` row write is uncommitted until the
|
|
84
|
+
* surrounding transaction completes, so a concurrent `withHandle` whose
|
|
85
|
+
* mutex region runs before commit would `#restore` from a missing row.
|
|
86
|
+
* `createGroup` and `joinGroup` only seed for fresh groups not yet visible
|
|
87
|
+
* to other callers, so this constraint is naturally satisfied in practice.
|
|
88
|
+
*/
|
|
89
|
+
seed(params: SeedParams): Promise<void>;
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{getKubunLogger as e}from"@kubun/logger";import{getP2PStore as t}from"@kubun/store-p2p";import{createMutex as r}from"../util/mutex.js";import{restoreMLSGroupHandle as s}from"./mls-group-handle.js";import{fromMLSStateRow as i,serializeMLSGroupState as a,toMLSStateInsert as l}from"./mls-state.js";export class GroupHandleRegistry{#e;#t;#r;#s=new Map;constructor(t){this.#e=t.stores,this.#t=t.deviceID,this.#r=t.logger??e("plugin-p2p:group-handle-registry")}withHandle(e,t,r){return this.#i(e,r,async(r,s)=>{let i=await t(r.handle);return await this.#a(s,e,r.handle),i})}withHandleReplacing(e,t,r){return this.#i(e,r,async(r,s)=>{let{result:i,updated:a}=await t(r.handle);return r.handle=a,await this.#a(s,e,a),i})}#i(e,t,r){let s=t?.stores??this.#e,i=this.#l(e);return i.mutex.run(async()=>(null==i.handle&&(i.handle=await this.#n(s,e)),r(i,s)))}invalidate(e){this.#s.delete(e)}async seed(e){let{groupID:r,handle:s,stores:i}=e,n=this.#s.get(r);if(n?.handle!=null)throw Error(`GroupHandleRegistry.seed: handle already cached for group ${r}`);let o=a(s),h=await t(i);await h.saveMLSState(l(o,r,this.#t)),i.onCommit(()=>{let e=this.#l(r);null==e.handle&&(e.handle=s)})}#l(e){let t=this.#s.get(e);return null==t&&(t={handle:null,mutex:r()},this.#s.set(e,t)),t}async #n(e,r){let a=await t(e),l=await a.getMLSState(r,this.#t);if(null==l)throw this.#r.debug("restore: no MLS state",{groupID:r,deviceID:this.#t}),Error(`No MLS state for group ${r}`);return this.#r.debug("restore: MLS state",{groupID:r,deviceID:this.#t,epoch:l.epoch}),await s(i(l))}async #a(e,r,s){let i=a(s),n=await t(e);await n.saveMLSState(l(i,r,this.#t)),this.#r.debug("persist: MLS state",{groupID:r,deviceID:this.#t,epoch:i.epoch})}}
|
|
@@ -8,13 +8,33 @@ export type FullJoinRequestPayload = {
|
|
|
8
8
|
publicPackage: KeyPackageBundle['publicPackage'];
|
|
9
9
|
privatePackage: KeyPackageBundle['privatePackage'];
|
|
10
10
|
};
|
|
11
|
+
export type SuggestedHub = {
|
|
12
|
+
url: string;
|
|
13
|
+
serverDID?: string;
|
|
14
|
+
};
|
|
11
15
|
export type InvitePayload = {
|
|
12
16
|
groupID: string;
|
|
13
17
|
groupName: string;
|
|
14
|
-
|
|
18
|
+
suggestedHubs?: Array<SuggestedHub>;
|
|
15
19
|
invite: Invite;
|
|
16
20
|
welcomeMessage: unknown;
|
|
17
21
|
ratchetTree: unknown;
|
|
22
|
+
/**
|
|
23
|
+
* Optional initial write-grant capability tokens (stringified JWTs) the
|
|
24
|
+
* inviter wants the joiner to hold immediately, before it starts receiving
|
|
25
|
+
* group broadcasts. Travels alongside the MLS invite as plain app-level data
|
|
26
|
+
* and is applied after the join completes — tokens addressed to the joiner
|
|
27
|
+
* are stored, others ignored.
|
|
28
|
+
*/
|
|
29
|
+
grants?: Array<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Optional initial signed-revocation tokens the inviter wants the joiner to
|
|
32
|
+
* apply immediately, before it starts receiving group broadcasts. Pending
|
|
33
|
+
* revocations (cap not yet known by the inviter) do NOT travel — only
|
|
34
|
+
* verified ones — though the receiving joiner re-cross-checks them anyway
|
|
35
|
+
* against any caps it already holds.
|
|
36
|
+
*/
|
|
37
|
+
revocations?: Array<string>;
|
|
18
38
|
};
|
|
19
39
|
export declare function encodeJoinRequest(payload: JoinRequestPayload): string;
|
|
20
40
|
export declare function decodeJoinRequest(encoded: string): JoinRequestPayload;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { StoreProvider } from '@kubun/db';
|
|
2
|
+
import type { HLC } from '@kubun/hlc';
|
|
3
|
+
import type { Logger } from '@kubun/logger';
|
|
2
4
|
import type { GroupData } from '../types.js';
|
|
3
|
-
import type {
|
|
5
|
+
import type { P2PEventEmitter } from './events.js';
|
|
4
6
|
export type FinalizeJoinedGroupParams = {
|
|
5
7
|
stores: StoreProvider;
|
|
6
|
-
emitter:
|
|
8
|
+
emitter: P2PEventEmitter;
|
|
7
9
|
invite: {
|
|
8
10
|
groupID: string;
|
|
9
11
|
};
|
|
@@ -11,3 +13,67 @@ export type FinalizeJoinedGroupParams = {
|
|
|
11
13
|
export declare function finalizeJoinedGroup(params: FinalizeJoinedGroupParams): Promise<{
|
|
12
14
|
group: GroupData | null;
|
|
13
15
|
}>;
|
|
16
|
+
export type ApplyInviteGrantsParams = {
|
|
17
|
+
stores: StoreProvider;
|
|
18
|
+
/** DID of the joining device — only grants addressed to it are stored. */
|
|
19
|
+
selfDID: string;
|
|
20
|
+
/** Group the joined invite belongs to. */
|
|
21
|
+
groupID: string;
|
|
22
|
+
/** Stringified capability JWTs carried by the invite. */
|
|
23
|
+
grants?: Array<string>;
|
|
24
|
+
/** Joiner's HLC, used to stamp each stored held row. */
|
|
25
|
+
hlc: HLC;
|
|
26
|
+
/**
|
|
27
|
+
* Optional emitter notified after each held row is written. Lets the joiner's
|
|
28
|
+
* `ownDelegationTokenAdded` subscription surface invite-bootstrap grants
|
|
29
|
+
* without waiting for a subsequent broadcast.
|
|
30
|
+
*/
|
|
31
|
+
emitter?: P2PEventEmitter;
|
|
32
|
+
logger?: Logger;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Apply invite-bootstrap grants after a join completes. Each token is verified
|
|
36
|
+
* and, when addressed to the joining device, stored as a held row so the
|
|
37
|
+
* engine's auto-attach can use it on the very first mutation — before any group
|
|
38
|
+
* broadcast arrives.
|
|
39
|
+
*
|
|
40
|
+
* No grantor HLC travels in the invite (it carries plain tokens, not the
|
|
41
|
+
* grantor's issued rows), so the joiner stamps a fresh HLC of its own at apply
|
|
42
|
+
* time. This is consistent with how every other join-time row the joiner
|
|
43
|
+
* materializes is locally stamped.
|
|
44
|
+
*
|
|
45
|
+
* Returns the number of grants that resulted in a stored held row.
|
|
46
|
+
*/
|
|
47
|
+
export declare function applyInviteGrants(params: ApplyInviteGrantsParams): Promise<number>;
|
|
48
|
+
export type ApplyInviteRevocationsParams = {
|
|
49
|
+
stores: StoreProvider;
|
|
50
|
+
/** Group the joined invite belongs to. */
|
|
51
|
+
groupID: string;
|
|
52
|
+
/** Signed revocation JWTs carried by the invite. */
|
|
53
|
+
revocations?: Array<string>;
|
|
54
|
+
/** Joiner's HLC, used to stamp each stored revocation row. */
|
|
55
|
+
hlc: HLC;
|
|
56
|
+
/**
|
|
57
|
+
* DID of the joining device. Forwarded to `storeReceivedRevocation` for
|
|
58
|
+
* symmetry; revocation storage is identity-agnostic but the helper accepts
|
|
59
|
+
* the param.
|
|
60
|
+
*/
|
|
61
|
+
selfDID: string;
|
|
62
|
+
/** Optional emitter forwarded to `storeReceivedRevocation`. */
|
|
63
|
+
emitter?: P2PEventEmitter;
|
|
64
|
+
logger?: Logger;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Apply invite-bootstrap revocations after a join completes. Each token is
|
|
68
|
+
* verified and stored — cap-known locally with matching `iss` yields a
|
|
69
|
+
* verified row, cap-unknown yields a pending row that the joiner will
|
|
70
|
+
* cross-check when the cap later arrives.
|
|
71
|
+
*
|
|
72
|
+
* The inviter's caller is responsible for filtering to verified revocations
|
|
73
|
+
* only; pending revocations do not travel via the invite envelope. The joiner
|
|
74
|
+
* stamps a fresh HLC since the inviter's HLC for these rows is not in the
|
|
75
|
+
* payload.
|
|
76
|
+
*
|
|
77
|
+
* Returns the number of revocations that resulted in a stored row.
|
|
78
|
+
*/
|
|
79
|
+
export declare function applyInviteRevocations(params: ApplyInviteRevocationsParams): Promise<number>;
|
package/lib/groups/join-utils.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{HLC as e}from"@kubun/hlc";import{getP2PStore as t}from"@kubun/store-p2p";import{toISO as r}from"../context/types.js";import{storeReceivedGrant as n}from"./store-received-grant.js";import{storeReceivedRevocation as i}from"./store-received-revocation.js";export async function finalizeJoinedGroup(e){let{stores:n,emitter:i,invite:o}=e,a=await t(n),l=await a.getGroup(o.groupID),p=null!=l?{id:l.id,name:l.name,description:l.description,createdBy:l.created_by,createdAt:r(l.created_at)}:null;return null!=p&&await i.emit("groupJoined",p),{group:p}}export async function applyInviteGrants(r){let{stores:i,selfDID:o,groupID:a,grants:l,hlc:p,emitter:u,logger:c}=r;if(null==l||0===l.length)return 0;let s=await t(i);return(await Promise.all(l.map(t=>n({p2pStore:s,token:t,groupID:a,hlc:e.serialize(p.now()),selfDID:o,...null!=u?{emitter:u}:{},...null!=c?{logger:c}:{}})))).reduce((e,t)=>e+ +!!t,0)}export async function applyInviteRevocations(r){let{stores:n,groupID:o,revocations:a,hlc:l,selfDID:p,emitter:u,logger:c}=r;if(null==a||0===a.length)return 0;let s=await t(n);return(await Promise.all(a.map(t=>i({p2pStore:s,token:t,groupID:o,hlc:e.serialize(l.now()),selfDID:p,...null!=u?{emitter:u}:{},...null!=c?{logger:c}:{}})))).reduce((e,t)=>e+ +!!t,0)}
|
package/lib/groups/manager.d.ts
CHANGED
|
@@ -1,22 +1,29 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type GroupPermission, type Invite, type KeyPackageBundle } from '@enkaku/group';
|
|
2
2
|
import type { Identity, OwnIdentity } from '@enkaku/token';
|
|
3
3
|
import type { StoreProvider } from '@kubun/db';
|
|
4
4
|
import type { GroupBroadcastMessage } from './broadcast.js';
|
|
5
|
+
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
6
|
+
import type { SuggestedHub } from './invite-payload.js';
|
|
5
7
|
export type GroupManagerParams = {
|
|
6
8
|
identity: Identity;
|
|
7
9
|
getRandomID: () => string;
|
|
10
|
+
/**
|
|
11
|
+
* Single canonical access point for the device's MLS `GroupHandle` instances.
|
|
12
|
+
* Required: all member ops + createGroup/joinGroup route MLS state through
|
|
13
|
+
* the registry's per-group mutex with write-through persist.
|
|
14
|
+
*/
|
|
15
|
+
registry: GroupHandleRegistry;
|
|
8
16
|
};
|
|
9
17
|
export type CreateGroupParams = {
|
|
10
18
|
stores: StoreProvider;
|
|
11
19
|
identity: OwnIdentity;
|
|
12
20
|
name: string;
|
|
13
21
|
description?: string;
|
|
14
|
-
|
|
22
|
+
hubs?: Array<SuggestedHub>;
|
|
15
23
|
createdBy?: string;
|
|
16
24
|
};
|
|
17
25
|
export type CreateGroupResult = {
|
|
18
26
|
groupID: string;
|
|
19
|
-
groupHandle: GroupHandle;
|
|
20
27
|
};
|
|
21
28
|
export type UpdateGroupParams = {
|
|
22
29
|
stores: StoreProvider;
|
|
@@ -24,7 +31,6 @@ export type UpdateGroupParams = {
|
|
|
24
31
|
update: {
|
|
25
32
|
name?: string;
|
|
26
33
|
description?: string;
|
|
27
|
-
hubURLs?: Array<string>;
|
|
28
34
|
};
|
|
29
35
|
};
|
|
30
36
|
export type UpdateGroupResult = {
|
|
@@ -34,51 +40,53 @@ export type InviteToGroupParams = {
|
|
|
34
40
|
stores: StoreProvider;
|
|
35
41
|
groupID: string;
|
|
36
42
|
identity: OwnIdentity;
|
|
37
|
-
groupHandle: GroupHandle;
|
|
38
43
|
recipientDID: string;
|
|
39
44
|
recipientKeyPackage: KeyPackageBundle['publicPackage'];
|
|
40
45
|
permission: GroupPermission;
|
|
41
46
|
};
|
|
42
47
|
export type InviteToGroupResult = {
|
|
43
48
|
invite: Invite;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
/** Framed MLSMessage(Welcome) bytes for the invitee (`@enkaku/group@0.16.1`). */
|
|
50
|
+
welcomeMessage: Uint8Array;
|
|
51
|
+
/** Framed MLSMessage(Commit) bytes to fan out to existing members. */
|
|
52
|
+
commitMessage: Uint8Array;
|
|
53
|
+
/**
|
|
54
|
+
* Ratchet tree from the post-commit handle. Required by `joinGroup`
|
|
55
|
+
* callers that build an invite payload — they no longer hold a
|
|
56
|
+
* `GroupHandle` reference, so the manager surfaces the tree directly.
|
|
57
|
+
* Typed `unknown` to match `@enkaku/group`'s `ProcessWelcomeParams.ratchetTree`,
|
|
58
|
+
* which is opaque on that side too.
|
|
59
|
+
*/
|
|
60
|
+
ratchetTree: unknown;
|
|
47
61
|
};
|
|
48
62
|
export type JoinGroupParams = {
|
|
49
63
|
stores: StoreProvider;
|
|
50
64
|
identity: OwnIdentity;
|
|
51
65
|
groupID: string;
|
|
52
66
|
groupName: string;
|
|
53
|
-
|
|
67
|
+
hubs?: Array<SuggestedHub>;
|
|
54
68
|
invite: Invite;
|
|
55
69
|
keyPackageBundle: KeyPackageBundle;
|
|
56
70
|
welcomeMessage: unknown;
|
|
57
71
|
ratchetTree: unknown;
|
|
58
72
|
};
|
|
59
|
-
export type JoinGroupResult =
|
|
60
|
-
groupHandle: GroupHandle;
|
|
61
|
-
};
|
|
73
|
+
export type JoinGroupResult = undefined;
|
|
62
74
|
export type RemoveMemberParams = {
|
|
63
75
|
stores: StoreProvider;
|
|
64
76
|
groupID: string;
|
|
65
|
-
groupHandle: GroupHandle;
|
|
66
77
|
leafIndex: number;
|
|
67
78
|
memberDID: string;
|
|
68
79
|
};
|
|
69
80
|
export type RemoveMemberResult = {
|
|
70
|
-
commitMessage:
|
|
71
|
-
updatedGroupHandle: GroupHandle;
|
|
81
|
+
commitMessage: Uint8Array;
|
|
72
82
|
};
|
|
73
83
|
export type RemoveGroupMemberParams = {
|
|
74
84
|
stores: StoreProvider;
|
|
75
85
|
groupID: string;
|
|
76
|
-
groupHandle: GroupHandle;
|
|
77
86
|
memberDID: string;
|
|
78
87
|
};
|
|
79
88
|
export type RemoveGroupMemberResult = {
|
|
80
|
-
commitMessage:
|
|
81
|
-
updatedGroupHandle: GroupHandle;
|
|
89
|
+
commitMessage: Uint8Array;
|
|
82
90
|
};
|
|
83
91
|
export type LeaveGroupParams = {
|
|
84
92
|
stores: StoreProvider;
|
package/lib/groups/manager.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{commitInvite as e,createGroup as
|
|
1
|
+
import{commitInvite as e,createGroup as r,createInvite as t,processWelcome as i,removeMember as a}from"@enkaku/group";import{HLC as o}from"@kubun/hlc";import{getGraphStore as s}from"@kubun/store-graph";import{getP2PStore as l}from"@kubun/store-p2p";import{bindHubToGroup as c,upsertHub as n}from"../hub/manager.js";export class GroupManager{#e;#r;#t;#i;constructor(e){this.#e=e.identity.id,this.#r=e.getRandomID,this.#t=new o({nodeID:e.identity.id}),this.#i=e.registry}async createGroup(e){let t=this.#r(),{group:i}=await r(e.identity,t),a=e.hubs??[];return await e.stores.withTransaction(async r=>{let s=await l(r);for(let l of(await s.createGroup({id:t,name:e.name,description:e.description??"",created_by:e.createdBy??e.identity.id,hlc:o.serialize(this.#t.now())}),await this.#i.seed({groupID:t,handle:i,stores:r}),await s.addGroupMember({group_id:t,member_did:e.identity.id,role:"admin",hlc:o.serialize(this.#t.now())}),a)){let e=await n({stores:r,url:l.url,serverDID:l.serverDID??null});await c({stores:r,hubID:e.id,groupID:t})}}),{groupID:t}}async updateGroup(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.updateGroup(e.groupID,{name:e.update.name,description:e.update.description,hlc:r}),{broadcast:{type:"group:update",groupID:e.groupID,update:{...e.update,hlc:r}}}}async inviteToGroup(r){let i=await this.#i.withHandleReplacing(r.groupID,async i=>{let{invite:a}=await t({group:i,identity:r.identity,recipientDID:r.recipientDID,permission:r.permission}),{commitMessage:o,welcomeMessage:s,newGroup:l}=await e(i,r.recipientKeyPackage);return{result:{invite:a,commitMessage:o,welcomeMessage:s,ratchetTree:l.state.ratchetTree},updated:l}},{stores:r.stores}),a=await l(r.stores);return await a.addGroupMember({group_id:r.groupID,member_did:r.recipientDID,role:"admin"===r.permission?"admin":"member",hlc:o.serialize(this.#t.now())}),i}async joinGroup(e){let{group:r}=await i({identity:e.identity,invite:e.invite,welcome:e.welcomeMessage,keyPackageBundle:e.keyPackageBundle,ratchetTree:e.ratchetTree}),t=e.hubs??[];await e.stores.withTransaction(async i=>{await this.#i.seed({groupID:e.groupID,handle:r,stores:i});let a=await l(i);for(let r of(null==await a.getGroup(e.groupID)&&await a.createGroup({id:e.groupID,name:e.groupName,description:"",created_by:e.identity.id,hlc:o.serialize(this.#t.now())}),await a.addGroupMember({group_id:e.groupID,member_did:e.identity.id,role:"member",hlc:o.serialize(this.#t.now())}),t)){let t=await n({stores:i,url:r.url,serverDID:r.serverDID??null});await c({stores:i,hubID:t.id,groupID:e.groupID})}})}async removeMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let{commitMessage:t,newGroup:i}=await a(r,e.leafIndex);return{result:{commitMessage:t},updated:i}},{stores:e.stores}),t=await l(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID,o.serialize(this.#t.now())),{commitMessage:r}}async removeGroupMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let t=r.findMemberLeafIndex(e.memberDID);if(null==t)throw Error(`Member ${e.memberDID} not found in MLS group`);let{commitMessage:i,newGroup:o}=await a(r,t);return{result:{commitMessage:i},updated:o}},{stores:e.stores}),t=await l(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID,o.serialize(this.#t.now())),{commitMessage:r}}async leaveGroup(e){let r=await l(e.stores);await r.deleteMLSState(e.groupID,this.#e),await r.removeGroupMember(e.groupID,e.identity.id,o.serialize(this.#t.now())),e.stores.onCommit(()=>this.#i.invalidate(e.groupID))}async loadGroup(e,r){let t=await l(e),i=await t.getMLSState(r,this.#e);if(null!=i)return{epoch:i.epoch,credential:i.credential}}async createCircle(e){let r=this.#r(),t=o.serialize(this.#t.now()),i=e.catalogIDs??[],a=await l(e.stores);return await a.createCircle({id:r,group_id:e.groupID,name:e.name,description:e.description??"",catalog_ids:i,hlc:t}),{circleID:r,broadcast:{type:"circle:create",circle:{id:r,groupID:e.groupID,name:e.name,description:e.description??"",catalogIDs:i,hlc:t}}}}async updateCircle(e){let r=o.serialize(this.#t.now()),t=[],i=await l(e.stores);if(null!=e.update.catalogIDs){let r=await i.getCircle(e.circleID),a=new Set(null!=r?r.catalog_ids:[]),o=e.update.catalogIDs.filter(e=>!a.has(e)),l=await s(e.stores);for(let e of(await Promise.all(o.map(e=>l.getCatalog(e)))))null!=e&&t.push({type:"catalog:create",catalog:{id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:e.filter_criteria,hlc:e.hlc}})}await i.updateCircle(e.circleID,{name:e.update.name,description:e.update.description,catalog_ids:e.update.catalogIDs,hlc:r});let a={type:"circle:update",circleID:e.circleID,update:{...e.update,hlc:r}};return{broadcast:a,broadcasts:[a,...t]}}async deleteCircle(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.deleteCircle(e.circleID),{broadcast:{type:"circle:delete",circleID:e.circleID,hlc:r}}}async addCircleMember(e){let r=await l(e.stores);if(!await r.isGroupMember(e.groupID,e.memberDID))throw Error(`${e.memberDID} is not a member of group ${e.groupID}`);let t=o.serialize(this.#t.now());return await r.addCircleMember({circle_id:e.circleID,member_did:e.memberDID,role:e.role,hlc:t}),{broadcast:{type:"member:add",member:{circleID:e.circleID,memberDID:e.memberDID,role:e.role,hlc:t}}}}async removeCircleMember(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.removeCircleMember(e.circleID,e.memberDID),{broadcast:{type:"member:remove",circleID:e.circleID,memberDID:e.memberDID,hlc:r}}}}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GroupHandle } from '@enkaku/group';
|
|
2
|
+
export type MLSEncryptFramedResult = {
|
|
3
|
+
framed: Uint8Array;
|
|
4
|
+
consumed: Array<Uint8Array>;
|
|
5
|
+
};
|
|
6
|
+
export declare function mlsEncryptFramed(handle: GroupHandle, plaintext: Uint8Array): Promise<MLSEncryptFramedResult>;
|
|
7
|
+
export declare function mlsDecryptFramed(handle: GroupHandle, framed: Uint8Array): Promise<Uint8Array>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{replacer as e,reviver as r}from"./mls-json.js";let t=new TextEncoder,n=new TextDecoder;export async function mlsEncryptFramed(r,n){let{message:c,consumed:o}=await r.encrypt(n);return{framed:t.encode(JSON.stringify(c,e)),consumed:o}}export async function mlsDecryptFramed(e,t){let c=JSON.parse(n.decode(t),r);return await e.decrypt(c)}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Encryptor } from '@enkaku/hub-tunnel';
|
|
2
|
+
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
3
|
+
export type MLSEncryptorParams = {
|
|
4
|
+
registry: GroupHandleRegistry;
|
|
5
|
+
groupID: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Adapts the `GroupHandleRegistry` to the `@enkaku/hub-tunnel` `Encryptor`
|
|
9
|
+
* interface. The registry's per-group mutex serializes encrypt + decrypt
|
|
10
|
+
* across all encryptor instances bound to the same `(registry, groupID)`.
|
|
11
|
+
*
|
|
12
|
+
* Wedge-protection note: encrypt persists the advanced ratchet immediately
|
|
13
|
+
* on callback success — BEFORE the wire send happens inside the hub-tunnel
|
|
14
|
+
* transport. If the wire send fails, the persisted state is already at
|
|
15
|
+
* generation N+1; the next encrypt advances to N+2 and generation N is
|
|
16
|
+
* "burned" without wire egress. This is the documented forward-secrecy
|
|
17
|
+
* trade-off for tunnel sessions; the broadcast path (`BroadcastService.
|
|
18
|
+
* prepareSend`) inverts the order by pulling send into the registry callback.
|
|
19
|
+
*/
|
|
20
|
+
export declare class MLSEncryptor implements Encryptor {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(params: MLSEncryptorParams);
|
|
23
|
+
encrypt(plaintext: Uint8Array): Promise<Uint8Array>;
|
|
24
|
+
decrypt(ciphertext: Uint8Array): Promise<Uint8Array>;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{mlsDecryptFramed as r,mlsEncryptFramed as t}from"./mls-codec.js";export class MLSEncryptor{#r;#t;constructor(r){this.#r=r.registry,this.#t=r.groupID}encrypt(r){return this.#r.withHandle(this.#t,async s=>{let{framed:e}=await t(s,r);return e})}decrypt(t){return this.#r.withHandle(this.#t,s=>r(s,t))}}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
3
|
+
import type { P2PEventEmitter } from './events.js';
|
|
4
|
+
export type StoreReceivedGrantParams = {
|
|
5
|
+
/** P2P store the held row is written to. */
|
|
6
|
+
p2pStore: P2PStoreAPI;
|
|
7
|
+
/** Stringified capability JWT carrying the `document/write` grant. */
|
|
8
|
+
token: string;
|
|
9
|
+
/** Group the grant belongs to. */
|
|
10
|
+
groupID: string;
|
|
11
|
+
/**
|
|
12
|
+
* HLC stamped on the held row. For broadcast-delivered grants this mirrors
|
|
13
|
+
* the grantor-side issued row; for invite-delivered grants the joiner stamps
|
|
14
|
+
* its own HLC at apply time (no grantor HLC travels in the invite).
|
|
15
|
+
*/
|
|
16
|
+
hlc: string;
|
|
17
|
+
/**
|
|
18
|
+
* DID of the receiving device. The grant is only stored when the token's
|
|
19
|
+
* audience (`aud`) matches this DID — co-members relay the same grant but
|
|
20
|
+
* ignore tokens addressed to someone else.
|
|
21
|
+
*/
|
|
22
|
+
selfDID: string;
|
|
23
|
+
/**
|
|
24
|
+
* Optional emitter notified after a held row is successfully written.
|
|
25
|
+
* Consumers (e.g. the `ownDelegationTokenAdded` subscription) use this to
|
|
26
|
+
* surface freshly-arrived tokens to local subscribers.
|
|
27
|
+
*/
|
|
28
|
+
emitter?: P2PEventEmitter;
|
|
29
|
+
/** Optional logger — warns on verification failure or missing claims. */
|
|
30
|
+
logger?: Logger;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Verify a received capability grant and, when it is addressed to this device,
|
|
34
|
+
* store it as a held row so the engine's auto-attach can reuse it on later
|
|
35
|
+
* mutations.
|
|
36
|
+
*
|
|
37
|
+
* Returns `true` when the held row was inserted or materially changed (LWW
|
|
38
|
+
* upsert with a newer hlc and at least one differing content field). Returns
|
|
39
|
+
* `false` when no row change occurred — verification failure, audience
|
|
40
|
+
* mismatch, missing required claims, LWW lost against an existing row, or an
|
|
41
|
+
* idempotent re-broadcast with identical content. Never throws on a forged or
|
|
42
|
+
* malformed token — it is skipped like an unverifiable broadcast entry.
|
|
43
|
+
*
|
|
44
|
+
* Shared by the `delegation:share` broadcast path and the invite-bootstrap
|
|
45
|
+
* path so both apply identical verify + audience + null-guard + coercion logic.
|
|
46
|
+
*/
|
|
47
|
+
export declare function storeReceivedGrant(params: StoreReceivedGrantParams): Promise<boolean>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{verifyToken as e}from"@enkaku/token";export async function storeReceivedGrant(i){let t,{p2pStore:a,token:n,groupID:r,hlc:o,selfDID:d,emitter:p,logger:s}=i;try{t=(await e(n)).payload}catch(e){return s?.warn("received grant token verification failed, skipping",{groupID:r,error:e}),!1}if(t.aud!==d)return!1;if(null==t.jti||null==t.exp)return s?.warn("received grant token missing jti/exp, skipping",{groupID:r}),!1;let u="string"==typeof t.res?t.res:JSON.stringify(t.res),c="string"==typeof t.act?t.act:JSON.stringify(t.act),g=await a.addDelegationToken({jti:t.jti,grantor:t.sub,audience:t.aud,token:n,resource:u,act:c,exp:t.exp,group_id:r,hlc:o});if(g){let e=await a.getPendingRevocationByJti(t.jti);if(null!=e)if(e.revoker_did===t.sub){let i=Math.floor(Date.now()/1e3);await a.markRevocationVerified(t.jti,{cap_exp:t.exp,verified_at:i})&&null!=p&&await p.emit("delegationTokenRevoked",{jti:t.jti,grantor:t.sub,audience:t.aud,revokerDID:e.revoker_did,revokedAt:e.revoked_iat,verifiedAt:i,capExp:t.exp,groupID:r})}else s?.warn("pending revocation iss does not match arriving cap, dropping",{jti:t.jti,pendingIss:e.revoker_did,capGrantor:t.sub,groupID:r}),await a.deletePendingRevocation(t.jti)}return g&&null!=p&&await p.emit("delegationTokenAdded",{jwt:n,jti:t.jti,grantor:t.sub,audience:t.aud,resource:u,exp:t.exp}),g}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
3
|
+
import type { P2PEventEmitter } from './events.js';
|
|
4
|
+
export type StoreReceivedRevocationParams = {
|
|
5
|
+
/** P2P store the revocation row is written to. */
|
|
6
|
+
p2pStore: P2PStoreAPI;
|
|
7
|
+
/** Stringified signed revocation JWT carrying `{ jti, iss, rev, iat }`. */
|
|
8
|
+
token: string;
|
|
9
|
+
/** Group the revocation belongs to. */
|
|
10
|
+
groupID: string;
|
|
11
|
+
/** HLC stamped on the revocation row for LWW arbitration. */
|
|
12
|
+
hlc: string;
|
|
13
|
+
/**
|
|
14
|
+
* DID of the receiving device. Currently unused — revocations are
|
|
15
|
+
* audience-agnostic at store time — but accepted for symmetry with
|
|
16
|
+
* `storeReceivedGrant` and to keep call sites uniform.
|
|
17
|
+
*/
|
|
18
|
+
selfDID?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional emitter passed through for symmetry with `storeReceivedGrant`.
|
|
21
|
+
* This helper does not emit; downstream subscription wiring lives elsewhere.
|
|
22
|
+
*/
|
|
23
|
+
emitter?: P2PEventEmitter;
|
|
24
|
+
/** Optional logger — debug on verification failure or malformed payload, warn on iss/grantor mismatch. */
|
|
25
|
+
logger?: Logger;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Verify a received revocation token and write its row to the P2P store.
|
|
29
|
+
*
|
|
30
|
+
* Verified-on-arrival: when the referenced capability is already known
|
|
31
|
+
* locally and the revocation's `iss` matches that capability's `grantor`,
|
|
32
|
+
* the row is stored with `verified_at` populated so the hot-path
|
|
33
|
+
* `isRevoked` gate fires immediately. When the capability is unknown
|
|
34
|
+
* locally, the row is stored as pending (`verified_at` null) until a later
|
|
35
|
+
* `delegation:share` triggers a cross-check that flips it via
|
|
36
|
+
* `markRevocationVerified`. A signature-verified token whose `iss` does
|
|
37
|
+
* not match a known capability's `grantor` is rejected outright — a forged
|
|
38
|
+
* revocation must not pollute the local store.
|
|
39
|
+
*
|
|
40
|
+
* Returns `true` when the row was inserted or materially changed (LWW
|
|
41
|
+
* upsert). Returns `false` on verification failure, non-revocation
|
|
42
|
+
* payload, iss/grantor mismatch, or a lost/idempotent LWW upsert. Never
|
|
43
|
+
* throws on a forged or malformed token — it is skipped like an
|
|
44
|
+
* unverifiable broadcast entry.
|
|
45
|
+
*/
|
|
46
|
+
export declare function storeReceivedRevocation(params: StoreReceivedRevocationParams): Promise<boolean>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{verifyToken as e}from"@enkaku/token";export async function storeReceivedRevocation(o){let r,{p2pStore:t,token:n,groupID:i,hlc:a,emitter:c,logger:d}=o;try{r=(await e(n)).payload}catch(e){return d?.debug("received revocation token verification failed, skipping",{groupID:i,error:e}),!1}if(!0!==r.rev)return d?.debug("received revocation token is not a revocation record, skipping",{groupID:i}),!1;let{jti:l,iss:v,iat:k}=r,u=await t.getDelegationTokenByJti(l),g=null,p=null;if(null!=u){if(u.grantor!==v)return d?.warn("received revocation iss does not match known capability grantor, skipping",{groupID:i,jti:l,iss:v,grantor:u.grantor}),!1;g=Math.floor(Date.now()/1e3),p=u.exp}let f=await t.addRevocation({jti:l,revoker_did:v,revoked_iat:k,revocation_token:n,verified_at:g,cap_exp:p,group_id:i,hlc:a});return f&&null!=g&&null!=u&&null!=c&&await c.emit("delegationTokenRevoked",{jti:l,grantor:u.grantor,audience:u.audience,revokerDID:v,revokedAt:k,verifiedAt:g,capExp:p,groupID:i}),f}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outer wire-frame discriminator for group hub payloads.
|
|
3
|
+
*
|
|
4
|
+
* The hub relay treats a group send payload as opaque bytes. Kubun prepends a
|
|
5
|
+
* single `kind` byte so the receiver can route a payload to the right decoder
|
|
6
|
+
* without trial-decryption:
|
|
7
|
+
*
|
|
8
|
+
* - `app` — an MLS-encrypted application broadcast (`GroupBroadcastMessage`),
|
|
9
|
+
* decoded via `handle.decrypt` + `deserializeBroadcast`.
|
|
10
|
+
* - `mls` — a framed MLS handshake message (a Commit), fed straight to
|
|
11
|
+
* `handle.processMessage`. The handshake is self-protected (encrypted
|
|
12
|
+
* `PrivateMessage`), so it is NOT wrapped in the application-encryption path.
|
|
13
|
+
*
|
|
14
|
+
* The discriminator lives entirely in kubun's payload bytes; the hub protocol
|
|
15
|
+
* is unchanged.
|
|
16
|
+
*/
|
|
17
|
+
export declare const WireKind: {
|
|
18
|
+
readonly app: 0;
|
|
19
|
+
readonly mls: 1;
|
|
20
|
+
};
|
|
21
|
+
export type WireKind = (typeof WireKind)[keyof typeof WireKind];
|
|
22
|
+
/** Prepend the `kind` byte to a payload. */
|
|
23
|
+
export declare function frameWire(kind: WireKind, payload: Uint8Array): Uint8Array;
|
|
24
|
+
/**
|
|
25
|
+
* Split a framed payload into its `kind` and the remaining payload bytes.
|
|
26
|
+
* Throws on an empty frame or an unrecognized `kind` byte. The returned
|
|
27
|
+
* payload is a view (`subarray`) over the input, not a copy.
|
|
28
|
+
*/
|
|
29
|
+
export declare function unframeWire(framed: Uint8Array): {
|
|
30
|
+
kind: WireKind;
|
|
31
|
+
payload: Uint8Array;
|
|
32
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const WireKind={app:0,mls:1};export function frameWire(r,e){let n=new Uint8Array(e.length+1);return n[0]=r,n.set(e,1),n}export function unframeWire(r){if(0===r.length)throw Error("empty wire frame");let e=r[0];if(e!==WireKind.app&&e!==WireKind.mls)throw Error(`unknown wire frame kind: ${e}`);return{kind:e,payload:r.subarray(1)}}
|
|
@@ -6,9 +6,33 @@ export type HubClientRef = {
|
|
|
6
6
|
client: Client<HubProtocol>;
|
|
7
7
|
refID: string;
|
|
8
8
|
};
|
|
9
|
+
/**
|
|
10
|
+
* Fired once per freshly-spawned hub client when its first signed response
|
|
11
|
+
* arrives. The pool binds the hub URL into the call site; consumers (hub
|
|
12
|
+
* manager `captureServerDID`) use the URL to look up the local hub row and
|
|
13
|
+
* apply the conflict matrix. Errors are caught and logged — capture never
|
|
14
|
+
* blocks message forwarding, even on `HubServerDIDConflictError`.
|
|
15
|
+
*/
|
|
16
|
+
export type OnServerDIDObserved = (params: {
|
|
17
|
+
hubURL: string;
|
|
18
|
+
serverDID: string;
|
|
19
|
+
}) => void | Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Resolves the locally-pinned `server_did` for a hub URL at spawn time. When
|
|
22
|
+
* the hub row for this URL exists and has `server_did` set, the pool passes
|
|
23
|
+
* it as `expectedServerDID` to the client factory so the wrapper enforces
|
|
24
|
+
* the pin. Returning `null` falls back to TOFU.
|
|
25
|
+
*
|
|
26
|
+
* Resolution happens fresh on every spawn so `updateHub({ serverDID: null })`
|
|
27
|
+
* re-arms TOFU automatically: the next disposed/evicted client triggers a
|
|
28
|
+
* fresh resolve.
|
|
29
|
+
*/
|
|
30
|
+
export type ResolvePinnedServerDID = (hubURL: string) => Promise<string | null>;
|
|
9
31
|
export type HubConnectionPoolParams = {
|
|
10
32
|
createHubClient: CreateHubClient;
|
|
11
33
|
logger?: Logger;
|
|
34
|
+
onServerDIDObserved?: OnServerDIDObserved;
|
|
35
|
+
resolvePinnedDID?: ResolvePinnedServerDID;
|
|
12
36
|
};
|
|
13
37
|
export declare class HubConnectionPool {
|
|
14
38
|
#private;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export class HubConnectionPool{#e;#t;#i=new Map;#
|
|
1
|
+
export class HubConnectionPool{#e;#t;#i;#r;#s=new Map;#n=0;constructor(e){this.#e=e.createHubClient,this.#t=e.logger,this.#i=e.onServerDIDObserved,this.#r=e.resolvePinnedDID}async acquire(e){let t=this.#s.get(e);if(null!=t&&null!=t.disposing&&(await t.disposing,this.#s.get(e)===t&&this.#s.delete(e),t=void 0),null==t){let i,r=this.#i;if(null!=this.#r)try{let t=await this.#r(e);null!=t&&(i=t)}catch(t){this.#t?.warn("failed to resolve pinned server DID, falling back to TOFU",{hubURL:e,error:t})}let s=await this.#e(e,{onServerDID:null==r?void 0:async t=>{try{await r({hubURL:e,serverDID:t})}catch(i){this.#t?.warn("hub server DID capture failed",{hubURL:e,serverDID:t,error:i})}},expectedServerDID:i,onMismatch:null==i?void 0:e=>{this.#t?.warn("hub server DID mismatch — pinning enforcement triggered",{hubURL:e.hubURL,expectedServerDID:e.expectedServerDID,observedServerDID:e.observedServerDID})}}),n={client:s,refs:new Set};t=n,this.#s.set(e,n),s.disposed.then(()=>{this.#s.get(e)===n&&(this.#t?.debug("hub client disposed externally, evicting pool entry",{hubURL:e,outstandingRefs:[...n.refs]}),this.#s.delete(e))}).catch(()=>{})}let i=String(++this.#n);return t.refs.add(i),{client:t.client,refID:i}}async release(e,t){let i=this.#s.get(e);if(null==i)return void this.#t?.debug("HubConnectionPool.release: unknown hubURL",{hubURL:e,refID:t});if(!i.refs.has(t))return void this.#t?.debug("HubConnectionPool.release: unknown refID",{hubURL:e,refID:t});if(i.refs.delete(t),i.refs.size>0)return;let r=i.client.dispose();i.disposing=r;try{await r}finally{this.#s.get(e)===i&&this.#s.delete(e)}}async disposeAll(){let e=Array.from(this.#s.entries());await Promise.all(e.map(async([e,t])=>{t.refs.size>0&&this.#t?.warn("Disposing hub client with outstanding refs",{hubURL:e,refs:Array.from(t.refs)}),await t.client.dispose()})),this.#s.clear()}}
|