@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.
Files changed (88) hide show
  1. package/lib/context/delegation.d.ts +4 -0
  2. package/lib/context/delegation.js +1 -0
  3. package/lib/context/group.js +1 -1
  4. package/lib/context/hub.d.ts +4 -0
  5. package/lib/context/hub.js +1 -0
  6. package/lib/context/join.js +1 -1
  7. package/lib/context/types.d.ts +28 -2
  8. package/lib/groups/broadcast-service.d.ts +130 -17
  9. package/lib/groups/broadcast-service.js +1 -1
  10. package/lib/groups/broadcast.d.ts +162 -5
  11. package/lib/groups/broadcast.js +1 -1
  12. package/lib/groups/events.d.ts +26 -5
  13. package/lib/groups/events.js +1 -1
  14. package/lib/groups/group-handle-registry.d.ts +90 -0
  15. package/lib/groups/group-handle-registry.js +1 -0
  16. package/lib/groups/invite-payload.d.ts +21 -1
  17. package/lib/groups/join-utils.d.ts +68 -2
  18. package/lib/groups/join-utils.js +1 -1
  19. package/lib/groups/manager.d.ts +26 -18
  20. package/lib/groups/manager.js +1 -1
  21. package/lib/groups/mls-codec.d.ts +7 -0
  22. package/lib/groups/mls-codec.js +1 -0
  23. package/lib/groups/mls-encryptor.d.ts +25 -0
  24. package/lib/groups/mls-encryptor.js +1 -0
  25. package/lib/groups/store-received-grant.d.ts +47 -0
  26. package/lib/groups/store-received-grant.js +1 -0
  27. package/lib/groups/store-received-revocation.d.ts +46 -0
  28. package/lib/groups/store-received-revocation.js +1 -0
  29. package/lib/groups/wire-frame.d.ts +32 -0
  30. package/lib/groups/wire-frame.js +1 -0
  31. package/lib/hub/connection-pool.d.ts +24 -0
  32. package/lib/hub/connection-pool.js +1 -1
  33. package/lib/hub/did-observing-transport.d.ts +64 -0
  34. package/lib/hub/did-observing-transport.js +1 -0
  35. package/lib/hub/errors.d.ts +28 -0
  36. package/lib/hub/errors.js +1 -0
  37. package/lib/hub/forward-remote-broadcast.d.ts +15 -0
  38. package/lib/hub/forward-remote-broadcast.js +1 -0
  39. package/lib/hub/group-channel.d.ts +37 -21
  40. package/lib/hub/group-channel.js +1 -1
  41. package/lib/hub/http-client.d.ts +17 -0
  42. package/lib/hub/http-client.js +1 -0
  43. package/lib/hub/hub-connection.d.ts +96 -0
  44. package/lib/hub/hub-connection.js +1 -0
  45. package/lib/hub/manager.d.ts +117 -0
  46. package/lib/hub/manager.js +1 -0
  47. package/lib/hub/receive-handler.d.ts +3 -6
  48. package/lib/hub/receive-handler.js +1 -1
  49. package/lib/hub/relay-manager.d.ts +92 -2
  50. package/lib/hub/relay-manager.js +1 -1
  51. package/lib/hub/send-handler.d.ts +28 -16
  52. package/lib/hub/send-handler.js +1 -1
  53. package/lib/hub/tunnel-inbox.d.ts +20 -0
  54. package/lib/hub/tunnel-inbox.js +1 -0
  55. package/lib/hub/wait-for-gate.d.ts +14 -0
  56. package/lib/hub/wait-for-gate.js +1 -0
  57. package/lib/hub/wiring.d.ts +85 -0
  58. package/lib/hub/wiring.js +1 -0
  59. package/lib/index.d.ts +102 -7
  60. package/lib/index.js +1 -1
  61. package/lib/schema.d.ts +2 -2
  62. package/lib/schema.js +113 -21
  63. package/lib/sync/broadcast-queue.d.ts +59 -0
  64. package/lib/sync/broadcast-queue.js +1 -0
  65. package/lib/sync/broadcast-sender.d.ts +52 -0
  66. package/lib/sync/broadcast-sender.js +1 -0
  67. package/lib/sync/forwarder.d.ts +97 -0
  68. package/lib/sync/forwarder.js +1 -0
  69. package/lib/sync/handlers.d.ts +20 -1
  70. package/lib/sync/handlers.js +1 -1
  71. package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
  72. package/lib/sync/hub-tunnel-sync-listener.js +1 -0
  73. package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
  74. package/lib/sync/hub-tunnel-sync-provider.js +1 -0
  75. package/lib/sync/merkle-apply.d.ts +41 -0
  76. package/lib/sync/merkle-apply.js +1 -1
  77. package/lib/sync/merkle-channel.d.ts +15 -0
  78. package/lib/sync/merkle-channel.js +1 -1
  79. package/lib/sync/receive-access-gate.d.ts +36 -0
  80. package/lib/sync/receive-access-gate.js +1 -0
  81. package/lib/sync/scope-resolver.d.ts +32 -0
  82. package/lib/sync/scope-resolver.js +1 -0
  83. package/lib/sync/sync-manager.d.ts +10 -0
  84. package/lib/sync/sync-manager.js +1 -1
  85. package/lib/types.d.ts +151 -1
  86. package/lib/util/mutex.d.ts +4 -0
  87. package/lib/util/mutex.js +1 -0
  88. 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
- hubURLs: Array<string>;
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 { GroupEventEmitter } from './events.js';
5
+ import type { P2PEventEmitter } from './events.js';
4
6
  export type FinalizeJoinedGroupParams = {
5
7
  stores: StoreProvider;
6
- emitter: GroupEventEmitter;
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>;
@@ -1 +1 @@
1
- import{getP2PStore as e}from"@kubun/store-p2p";import{toISO as t}from"../context/types.js";export async function finalizeJoinedGroup(r){let{stores:i,emitter:o,invite:n}=r,a=await e(i),p=await a.getGroup(n.groupID),u=null!=p?{id:p.id,name:p.name,description:p.description,createdBy:p.created_by,createdAt:t(p.created_at)}:null;return null!=u&&await o.emit("groupJoined",u),{group:u}}
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)}
@@ -1,22 +1,29 @@
1
- import { type GroupHandle, type GroupPermission, type Invite, type KeyPackageBundle } from '@enkaku/group';
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
- hubURLs?: Array<string>;
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
- welcomeMessage: unknown;
45
- commitMessage: unknown;
46
- updatedGroupHandle: GroupHandle;
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
- hubURLs: Array<string>;
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: unknown;
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: unknown;
81
- updatedGroupHandle: GroupHandle;
89
+ commitMessage: Uint8Array;
82
90
  };
83
91
  export type LeaveGroupParams = {
84
92
  stores: StoreProvider;
@@ -1 +1 @@
1
- import{commitInvite as e,createGroup as t,createInvite as r,processWelcome as a,removeMember as i}from"@enkaku/group";import{HLC as o}from"@kubun/hlc";import{getGraphStore as s}from"@kubun/store-graph";import{getP2PStore as c}from"@kubun/store-p2p";import{serializeMLSGroupState as d}from"./mls-state.js";export class GroupManager{#e;#t;#r;constructor(e){this.#e=e.identity.id,this.#t=e.getRandomID,this.#r=new o({nodeID:e.identity.id})}async #a(e,t,r){let a=d(r),i=await c(e);await i.saveMLSState({group_id:t,device_id:this.#e,mls_state:a.mlsState,credential:a.credential,epoch:a.epoch,root_capability:a.rootCapability})}async createGroup(e){let r=this.#t(),{group:a}=await t(e.identity,r),i=await c(e.stores);return await i.createGroup({id:r,name:e.name,description:e.description??"",created_by:e.createdBy??e.identity.id,hub_urls:e.hubURLs??[],hlc:o.serialize(this.#r.now())}),await this.#a(e.stores,r,a),await i.addGroupMember({group_id:r,member_did:e.identity.id,role:"admin"}),{groupID:r,groupHandle:a}}async updateGroup(e){let t=o.serialize(this.#r.now()),r=await c(e.stores);return await r.updateGroup(e.groupID,{name:e.update.name,description:e.update.description,hub_urls:e.update.hubURLs,hlc:t}),{broadcast:{type:"group:update",groupID:e.groupID,update:{...e.update,hlc:t}}}}async inviteToGroup(t){let{invite:a}=await r({group:t.groupHandle,identity:t.identity,recipientDID:t.recipientDID,permission:t.permission}),{commitMessage:i,welcomeMessage:o,newGroup:s}=await e(t.groupHandle,t.recipientKeyPackage);await this.#a(t.stores,t.groupID,s);let d=await c(t.stores);return await d.addGroupMember({group_id:t.groupID,member_did:t.recipientDID,role:"admin"===t.permission?"admin":"member"}),{invite:a,welcomeMessage:o,commitMessage:i,updatedGroupHandle:s}}async joinGroup(e){let{group:t}=await a({identity:e.identity,invite:e.invite,welcome:e.welcomeMessage,keyPackageBundle:e.keyPackageBundle,ratchetTree:e.ratchetTree});await this.#a(e.stores,e.groupID,t);let r=await c(e.stores);return null==await r.getGroup(e.groupID)&&await r.createGroup({id:e.groupID,name:e.groupName,description:"",created_by:e.identity.id,hub_urls:e.hubURLs,hlc:o.serialize(this.#r.now())}),await r.addGroupMember({group_id:e.groupID,member_did:e.identity.id,role:"member"}),{groupHandle:t}}async removeMember(e){let{commitMessage:t,newGroup:r}=await i(e.groupHandle,e.leafIndex);await this.#a(e.stores,e.groupID,r);let a=await c(e.stores);return await a.removeGroupMember(e.groupID,e.memberDID),{commitMessage:t,updatedGroupHandle:r}}async removeGroupMember(e){let t=e.groupHandle.findMemberLeafIndex(e.memberDID);if(null==t)throw Error(`Member ${e.memberDID} not found in MLS group`);let{commitMessage:r,newGroup:a}=await i(e.groupHandle,t);await this.#a(e.stores,e.groupID,a);let o=await c(e.stores);return await o.removeGroupMember(e.groupID,e.memberDID),{commitMessage:r,updatedGroupHandle:a}}async leaveGroup(e){let t=await c(e.stores);await t.deleteMLSState(e.groupID,this.#e),await t.removeGroupMember(e.groupID,e.identity.id)}async loadGroup(e,t){let r=await c(e),a=await r.getMLSState(t,this.#e);if(null!=a)return{epoch:a.epoch,credential:a.credential}}async createCircle(e){let t=this.#t(),r=o.serialize(this.#r.now()),a=e.catalogIDs??[],i=await c(e.stores);return await i.createCircle({id:t,group_id:e.groupID,name:e.name,description:e.description??"",catalog_ids:a,hlc:r}),{circleID:t,broadcast:{type:"circle:create",circle:{id:t,groupID:e.groupID,name:e.name,description:e.description??"",catalogIDs:a,hlc:r}}}}async updateCircle(e){let t=o.serialize(this.#r.now()),r=[],a=await c(e.stores);if(null!=e.update.catalogIDs){let t=await a.getCircle(e.circleID),i=new Set(null!=t?t.catalog_ids:[]),o=e.update.catalogIDs.filter(e=>!i.has(e)),c=await s(e.stores);for(let e of(await Promise.all(o.map(e=>c.getCatalog(e)))))null!=e&&r.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 a.updateCircle(e.circleID,{name:e.update.name,description:e.update.description,catalog_ids:e.update.catalogIDs,hlc:t});let i={type:"circle:update",circleID:e.circleID,update:{...e.update,hlc:t}};return{broadcast:i,broadcasts:[i,...r]}}async deleteCircle(e){let t=o.serialize(this.#r.now()),r=await c(e.stores);return await r.deleteCircle(e.circleID),{broadcast:{type:"circle:delete",circleID:e.circleID,hlc:t}}}async addCircleMember(e){let t=await c(e.stores);if(!await t.isGroupMember(e.groupID,e.memberDID))throw Error(`${e.memberDID} is not a member of group ${e.groupID}`);let r=o.serialize(this.#r.now());return await t.addCircleMember({circle_id:e.circleID,member_did:e.memberDID,role:e.role,hlc:r}),{broadcast:{type:"member:add",member:{circleID:e.circleID,memberDID:e.memberDID,role:e.role,hlc:r}}}}async removeCircleMember(e){let t=o.serialize(this.#r.now()),r=await c(e.stores);return await r.removeCircleMember(e.circleID,e.memberDID),{broadcast:{type:"member:remove",circleID:e.circleID,memberDID:e.memberDID,hlc:t}}}}
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;#s=0;constructor(e){this.#e=e.createHubClient,this.#t=e.logger}async acquire(e){let t=this.#i.get(e);if(null!=t&&null!=t.disposing&&(await t.disposing,this.#i.get(e)===t&&this.#i.delete(e),t=void 0),null==t){let i=await this.#e(e),s={client:i,refs:new Set};t=s,this.#i.set(e,s),i.disposed.then(()=>{this.#i.get(e)===s&&(this.#t?.debug("hub client disposed externally, evicting pool entry",{hubURL:e,outstandingRefs:[...s.refs]}),this.#i.delete(e))}).catch(()=>{})}let i=String(++this.#s);return t.refs.add(i),{client:t.client,refID:i}}async release(e,t){let i=this.#i.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 s=i.client.dispose();i.disposing=s;try{await s}finally{this.#i.get(e)===i&&this.#i.delete(e)}}async disposeAll(){let e=Array.from(this.#i.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.#i.clear()}}
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()}}