@kubun/plugin-p2p 0.9.0 → 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 (49) 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/join.js +1 -1
  5. package/lib/context/types.d.ts +16 -2
  6. package/lib/groups/broadcast-service.d.ts +60 -1
  7. package/lib/groups/broadcast-service.js +1 -1
  8. package/lib/groups/broadcast.d.ts +50 -0
  9. package/lib/groups/broadcast.js +1 -1
  10. package/lib/groups/events.d.ts +15 -5
  11. package/lib/groups/events.js +1 -1
  12. package/lib/groups/group-handle-registry.js +1 -1
  13. package/lib/groups/invite-payload.d.ts +16 -0
  14. package/lib/groups/join-utils.d.ts +68 -2
  15. package/lib/groups/join-utils.js +1 -1
  16. package/lib/groups/manager.d.ts +6 -4
  17. package/lib/groups/manager.js +1 -1
  18. package/lib/groups/store-received-grant.d.ts +47 -0
  19. package/lib/groups/store-received-grant.js +1 -0
  20. package/lib/groups/store-received-revocation.d.ts +46 -0
  21. package/lib/groups/store-received-revocation.js +1 -0
  22. package/lib/groups/wire-frame.d.ts +32 -0
  23. package/lib/groups/wire-frame.js +1 -0
  24. package/lib/hub/forward-remote-broadcast.d.ts +3 -3
  25. package/lib/hub/forward-remote-broadcast.js +1 -1
  26. package/lib/hub/group-channel.d.ts +8 -0
  27. package/lib/hub/group-channel.js +1 -1
  28. package/lib/hub/hub-connection.js +1 -1
  29. package/lib/hub/receive-handler.js +1 -1
  30. package/lib/hub/relay-manager.d.ts +7 -0
  31. package/lib/hub/relay-manager.js +1 -1
  32. package/lib/hub/send-handler.d.ts +16 -0
  33. package/lib/hub/send-handler.js +1 -1
  34. package/lib/hub/wiring.d.ts +14 -2
  35. package/lib/hub/wiring.js +1 -1
  36. package/lib/index.d.ts +1 -2
  37. package/lib/index.js +1 -1
  38. package/lib/schema.d.ts +2 -2
  39. package/lib/schema.js +47 -3
  40. package/lib/sync/broadcast-sender.js +1 -1
  41. package/lib/sync/forwarder.d.ts +1 -0
  42. package/lib/sync/forwarder.js +1 -1
  43. package/lib/sync/handlers.d.ts +5 -1
  44. package/lib/sync/handlers.js +1 -1
  45. package/lib/sync/merkle-apply.js +1 -1
  46. package/lib/types.d.ts +92 -1
  47. package/package.json +40 -40
  48. package/lib/sync/catalog-match.d.ts +0 -13
  49. package/lib/sync/catalog-match.js +0 -1
@@ -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)}}
@@ -1,9 +1,9 @@
1
1
  import type { Logger } from '@kubun/logger';
2
2
  import type { BroadcastEvent } from '../groups/broadcast-service.js';
3
- import type { GroupEventEmitter } from '../groups/events.js';
3
+ import type { P2PEventEmitter } from '../groups/events.js';
4
4
  /**
5
5
  * Map a `BroadcastEvent` from `BroadcastService` into the matching
6
- * `GroupEventEmitter` event.
6
+ * `P2PEventEmitter` event.
7
7
  *
8
8
  * - Events with `applied === false` are ignored (no state change).
9
9
  * - `catalog:*` messages are currently a no-op. Catalog send-side broadcasts
@@ -12,4 +12,4 @@ import type { GroupEventEmitter } from '../groups/events.js';
12
12
  * captures at the appropriate moment (after-state for create / update,
13
13
  * before-state for delete / remove).
14
14
  */
15
- export declare function forwardRemoteBroadcast(emitter: GroupEventEmitter, event: BroadcastEvent, logger?: Logger): Promise<void>;
15
+ export declare function forwardRemoteBroadcast(emitter: P2PEventEmitter, event: BroadcastEvent, logger?: Logger): Promise<void>;
@@ -1 +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(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
+ 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})}}}
@@ -56,4 +56,12 @@ export declare class GroupChannel extends Disposer {
56
56
  broadcast(message: GroupBroadcastMessage, opts?: {
57
57
  timeoutMs?: number;
58
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>;
59
67
  }
@@ -1 +1 @@
1
- import{Disposer as e}from"@enkaku/async";import{handleReceivedMessage as t}from"./receive-handler.js";import{handleSendBroadcast as r}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;#l=!1;constructor(e){super({dispose:async()=>{await this.#d()}}),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.#l)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.#l=!0}#p(e){return t({groupID:this.#n,message:e,broadcastService:this.#u,logger:this.#i})}async close(){this.#l&&await this.dispose()}async #d(){if(this.#l){try{await this.#a.unsubscribe(this.#n)}catch(e){this.#i?.warn("hub-connection unsubscribe failed during close",{groupID:this.#n,error:e})}this.#l=!1}}broadcast(e,t){return this.#l?this.#a.runInGroup(this.#n,async()=>{if(!this.#l)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`))}}
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`))}}
@@ -1 +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;#w=new Map;#y;constructor(e){super({dispose:async()=>{await this.#I()}}),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.#y=new n}get events(){return this.#y}async #D(e){try{await this.#y.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.#L(),this.#f=!0,await this.#D({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 #L(){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,isAllowedSender:t,onRejected:s}){if(this.#w.has(e))throw Error("tunnelMessageStream: peerDID already registered");let n=new c,i={inbox:n,isAllowedSender:t??(()=>!0),onRejected:s};this.#w.set(e,i);let r=!1;return{[Symbol.asyncIterator]:()=>n.iterator(),return:()=>{r||(r=!0,this.#w.get(e)===i&&this.#w.delete(e),n.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.#k(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.#D({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.#D({type:"reconnecting",attempt:this.#g,delayMs:e});try{this.#b?.dispose(),this.#b=null,this.#u=null,await this.#L(),this.#g=0,await this.#D({type:"connected"});return}catch(e){this.#n?.warn("hub-connection reconnect attempt failed",{hubURL:this.#e,attempt:this.#g,error:e}),await this.#D({type:"error",error:e})}}}finally{this.#m=null,e()}}#k(e){if(null==e.groupID){let s=this.#w.get(e.senderDID);if(null!=s){if(!s.isAllowedSender(e.senderDID)){this.#n?.debug("tunnel frame rejected: sender not member",{hubURL:this.#e,sequenceID:e.sequenceID,senderDID:e.senderDID});try{s.onRejected?.("sender-not-member",e.senderDID)}catch(t){this.#n?.warn("tunnel onRejected callback threw",{hubURL:this.#e,senderDID:e.senderDID,error:t})}this.#b?.add(e.sequenceID);return}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 #I(){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.#w.values()))e.inbox.close();this.#w.clear(),this.#f=!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}}
@@ -1 +1 @@
1
- import{fromB64 as e}from"@enkaku/codec";import{ApplyError as r,DecryptError as c}from"../groups/broadcast-service.js";export async function handleReceivedMessage(s){let{groupID:a,message:n,broadcastService:u,logger:i}=s;if(n.groupID!==a)return i?.debug("hub message groupID mismatch, skipping",{groupID:a,messageGroupID:n.groupID,sequenceID:n.sequenceID}),{ack:!1};let o=e(n.payload);try{await u.processReceived({groupID:a,encrypted:o})}catch(e){if(e instanceof c)return i?.debug("decrypt failed, acking and skipping",{groupID:a,sequenceID:n.sequenceID,error:e}),{ack:!0};if(e instanceof r)return i?.error("broadcast apply failed, will redeliver",{groupID:a,sequenceID:n.sequenceID,error:e}),{ack:!1};return i?.error("unexpected error processing hub message",{groupID:a,sequenceID:n.sequenceID,error:e}),{ack:!1}}return{ack:!0}}
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}}
@@ -115,6 +115,13 @@ export declare class HubRelayManager {
115
115
  broadcast(groupID: string, message: GroupBroadcastMessage, opts?: {
116
116
  timeoutMs?: number;
117
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>;
118
125
  stop(): Promise<void>;
119
126
  }
120
127
  export {};
@@ -1 +1 @@
1
- import{EventEmitter as e}from"@enkaku/event";import{HubConnectionPool as t}from"./connection-pool.js";import{GroupChannel as s}from"./group-channel.js";import{HubConnection as r}from"./hub-connection.js";export class HubRelayManager{#e;#t;#s;#r;#o;#a;#i;#n;#l;#c;#u;#h;#p;#g=new Map;#d=new Map;#b=new Map;#f=new Map;relayEvents;constructor(o){this.#e=o.p2pStore,this.#t=o.broadcastService,this.#s=o.deviceID,this.#o=o.logger,this.#a=o.ackFlushMs??500,this.#i=o.ackFlushMax??10,this.#n=o.backoffBaseMs??1e3,this.#l=o.backoffMaxMs??6e4,this.#c=o.backoffJitter??.25,this.#u=o.broadcastTimeoutMs;let a=o.p2pStore;this.#r=new t({createHubClient:o.createHubClient,logger:o.logger,onServerDIDObserved:o.onServerDIDObserved,resolvePinnedDID:async e=>{let t=await a.getHubByURL(e);return t?.server_did??null}}),this.#h=o.createGroupChannel??(e=>new s(e)),this.#p=o.createHubConnection??(e=>new r(e)),this.relayEvents=new e}async #M(e){let t=this.#g.get(e);if(null!=t)return t;let s=this.#p({hubURL:e,deviceID:this.#s,pool:this.#r,logger:this.#o,ackFlushMs:this.#a,ackFlushMax:this.#i,backoffBaseMs:this.#n,backoffMaxMs:this.#l,backoffJitter:this.#c});try{await s.open()}catch(e){try{await s.dispose()}catch{}throw e}let r=s.events.on("lifecycle",async t=>{let s=Object.freeze([...this.#b.entries()].filter(([,t])=>t===e).map(([e])=>e));await this.relayEvents.emit("relay",{...t,hubURL:e,groupIDs:s})});return this.#f.set(e,r),this.#g.set(e,s),s}async start(e){(await Promise.allSettled(e.map(e=>this.addGroup(e)))).forEach((t,s)=>{"rejected"===t.status&&this.#o?.error("addGroup failed during start",{groupID:e[s],error:t.reason})})}async addGroup(e){if(this.#d.has(e))return void this.#o?.debug("group already added",{groupID:e});if(null==await this.#e.getGroup(e))throw Error(`Group ${e} not found`);let t=await this.#e.listHubsByGroupID(e);0===t.length?this.#o?.warn("group has no hub bindings, skipping",{groupID:e}):(t.length>1&&this.#o?.warn("multi-hub not yet supported, using first",{groupID:e,hubURLs:t.map(e=>e.url)}),await this.#v(e,t[0].url))}async #v(e,t){let s=await this.#M(t),r=this.#h({groupID:e,deviceID:this.#s,hubConnection:s,broadcastService:this.#t,p2pStore:this.#e,logger:this.#o});await r.open(),this.#d.set(e,r),this.#b.set(e,t)}async addBinding(e,t){let s=this.#b.get(e);null!=s?s===t?this.#o?.debug("binding already active",{groupID:e,hubURL:t}):this.#o?.warn("multi-hub not yet supported, ignoring extra binding",{groupID:e,activeHubURL:s,newHubURL:t}):await this.#v(e,t)}async removeBinding(e,t){this.#b.get(e)!==t?this.#o?.debug("binding not active, ignoring removal",{groupID:e,hubURL:t}):await this.removeGroup(e)}async removeGroup(e){let t=this.#d.get(e);null!=t&&(await t.close(),this.#d.delete(e),this.#b.delete(e))}async broadcast(e,t,s){let r=this.#d.get(e);if(null==r)throw Error(`Group ${e} is not active in HubRelayManager`);let o=s?.timeoutMs??this.#u;await r.broadcast(t,{timeoutMs:o})}async stop(){for(let e of this.#f.values())e();this.#f.clear();let e=[...this.#d.values()];this.#d.clear(),this.#b.clear(),(await Promise.allSettled(e.map(e=>e.close()))).forEach(e=>{"rejected"===e.status&&this.#o?.warn("GroupChannel close failed",{error:e.reason})});let t=[...this.#g.values()];this.#g.clear(),(await Promise.allSettled(t.map(e=>e.close()))).forEach(e=>{"rejected"===e.status&&this.#o?.warn("HubConnection close failed",{error:e.reason})}),await this.#r.disposeAll()}}
1
+ import{EventEmitter as e}from"@enkaku/event";import{HubConnectionPool as t}from"./connection-pool.js";import{GroupChannel as s}from"./group-channel.js";import{HubConnection as r}from"./hub-connection.js";export class HubRelayManager{#e;#t;#s;#r;#o;#a;#i;#n;#l;#c;#u;#h;#p;#g=new Map;#d=new Map;#b=new Map;#f=new Map;relayEvents;constructor(o){this.#e=o.p2pStore,this.#t=o.broadcastService,this.#s=o.deviceID,this.#o=o.logger,this.#a=o.ackFlushMs??500,this.#i=o.ackFlushMax??10,this.#n=o.backoffBaseMs??1e3,this.#l=o.backoffMaxMs??6e4,this.#c=o.backoffJitter??.25,this.#u=o.broadcastTimeoutMs;let a=o.p2pStore;this.#r=new t({createHubClient:o.createHubClient,logger:o.logger,onServerDIDObserved:o.onServerDIDObserved,resolvePinnedDID:async e=>{let t=await a.getHubByURL(e);return t?.server_did??null}}),this.#h=o.createGroupChannel??(e=>new s(e)),this.#p=o.createHubConnection??(e=>new r(e)),this.relayEvents=new e}async #M(e){let t=this.#g.get(e);if(null!=t)return t;let s=this.#p({hubURL:e,deviceID:this.#s,pool:this.#r,logger:this.#o,ackFlushMs:this.#a,ackFlushMax:this.#i,backoffBaseMs:this.#n,backoffMaxMs:this.#l,backoffJitter:this.#c});try{await s.open()}catch(e){try{await s.dispose()}catch{}throw e}let r=s.events.on("lifecycle",async t=>{let s=Object.freeze([...this.#b.entries()].filter(([,t])=>t===e).map(([e])=>e));await this.relayEvents.emit("relay",{...t,hubURL:e,groupIDs:s})});return this.#f.set(e,r),this.#g.set(e,s),s}async start(e){(await Promise.allSettled(e.map(e=>this.addGroup(e)))).forEach((t,s)=>{"rejected"===t.status&&this.#o?.error("addGroup failed during start",{groupID:e[s],error:t.reason})})}async addGroup(e){if(this.#d.has(e))return void this.#o?.debug("group already added",{groupID:e});if(null==await this.#e.getGroup(e))throw Error(`Group ${e} not found`);let t=await this.#e.listHubsByGroupID(e);0===t.length?this.#o?.warn("group has no hub bindings, skipping",{groupID:e}):(t.length>1&&this.#o?.warn("multi-hub not yet supported, using first",{groupID:e,hubURLs:t.map(e=>e.url)}),await this.#m(e,t[0].url))}async #m(e,t){let s=await this.#M(t),r=this.#h({groupID:e,deviceID:this.#s,hubConnection:s,broadcastService:this.#t,p2pStore:this.#e,logger:this.#o});await r.open(),this.#d.set(e,r),this.#b.set(e,t)}async addBinding(e,t){let s=this.#b.get(e);null!=s?s===t?this.#o?.debug("binding already active",{groupID:e,hubURL:t}):this.#o?.warn("multi-hub not yet supported, ignoring extra binding",{groupID:e,activeHubURL:s,newHubURL:t}):await this.#m(e,t)}async removeBinding(e,t){this.#b.get(e)!==t?this.#o?.debug("binding not active, ignoring removal",{groupID:e,hubURL:t}):await this.removeGroup(e)}async removeGroup(e){let t=this.#d.get(e);null!=t&&(await t.close(),this.#d.delete(e),this.#b.delete(e))}async broadcast(e,t,s){let r=this.#d.get(e);if(null==r)throw Error(`Group ${e} is not active in HubRelayManager`);let o=s?.timeoutMs??this.#u;await r.broadcast(t,{timeoutMs:o})}async sendCommit(e,t,s){let r=this.#d.get(e);if(null==r)throw Error(`Group ${e} is not active in HubRelayManager`);let o=s?.timeoutMs??this.#u;await r.sendCommit(t,{timeoutMs:o})}async stop(){for(let e of this.#f.values())e();this.#f.clear();let e=[...this.#d.values()];this.#d.clear(),this.#b.clear(),(await Promise.allSettled(e.map(e=>e.close()))).forEach(e=>{"rejected"===e.status&&this.#o?.warn("GroupChannel close failed",{error:e.reason})});let t=[...this.#g.values()];this.#g.clear(),(await Promise.allSettled(t.map(e=>e.close()))).forEach(e=>{"rejected"===e.status&&this.#o?.warn("HubConnection close failed",{error:e.reason})}),await this.#r.disposeAll()}}
@@ -22,3 +22,19 @@ export type HandleSendBroadcastParams = {
22
22
  * runs INSIDE the registry's per-group lock and a throw skips persist.
23
23
  */
24
24
  export declare function handleSendBroadcast(params: HandleSendBroadcastParams): Promise<void>;
25
+ export type HandleSendCommitParams = {
26
+ groupID: string;
27
+ /** Framed MLSMessage(Commit) bytes from the committer's `GroupManager` call. */
28
+ commitBytes: Uint8Array;
29
+ broadcastService: BroadcastService;
30
+ /** Same wire-send callback as {@link HandleSendBroadcastParams.send}. */
31
+ send: (groupID: string, payloadB64: string) => Promise<void>;
32
+ logger?: Logger;
33
+ };
34
+ /**
35
+ * Fan out a pre-produced MLS handshake Commit to a group's existing members.
36
+ * Unlike {@link handleSendBroadcast} this does not encrypt — the Commit is
37
+ * already a self-protected `PrivateMessage`; `prepareSendCommit` just tags it
38
+ * `mls` on the wire.
39
+ */
40
+ export declare function handleSendCommit(params: HandleSendCommitParams): Promise<void>;
@@ -1 +1 @@
1
- import{toB64 as r}from"@enkaku/codec";export async function handleSendBroadcast(e){let{groupID:a,message:o,broadcastService:n,send:t,logger:d}=e;try{await n.prepareSend({groupID:a,message:o,send:e=>t(a,r(e))})}catch(r){throw d?.error("hub/group/send failed",{groupID:a,error:r}),r}}
1
+ import{toB64 as r}from"@enkaku/codec";export async function handleSendBroadcast(e){let{groupID:o,message:t,broadcastService:a,send:n,logger:d}=e;try{await a.prepareSend({groupID:o,message:t,send:e=>n(o,r(e))})}catch(r){throw d?.error("hub/group/send failed",{groupID:o,error:r}),r}}export async function handleSendCommit(e){let{groupID:o,commitBytes:t,broadcastService:a,send:n,logger:d}=e;try{await a.prepareSendCommit({groupID:o,commitBytes:t,send:e=>n(o,r(e))})}catch(r){throw d?.error("hub/group/send (mls commit) failed",{groupID:o,error:r}),r}}
@@ -4,7 +4,7 @@ import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
4
4
  import type { Logger } from '@kubun/logger';
5
5
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
6
6
  import { type BroadcastEvent } from '../groups/broadcast-service.js';
7
- import type { GroupEventEmitter } from '../groups/events.js';
7
+ import type { P2PEventEmitter } from '../groups/events.js';
8
8
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
9
9
  import type { ForwardingConfig } from '../sync/forwarder.js';
10
10
  import type { StoreUnreadableMode } from '../types.js';
@@ -15,6 +15,18 @@ import { type CreateHubClient } from './relay-manager.js';
15
15
  export type HubWiring = {
16
16
  ready: Promise<void>;
17
17
  scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
18
+ /**
19
+ * Awaitable broadcast — resolves only after the message is encrypted + sent.
20
+ * Use when ordering matters relative to a subsequent local op (e.g. a
21
+ * leave-request that must be encrypted with the still-live MLS handle before
22
+ * `leaveGroup` tears it down). Rejects on send failure.
23
+ */
24
+ broadcastNow: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
25
+ /**
26
+ * Fire-and-forget fan-out of a pre-produced MLS handshake Commit to a
27
+ * group's existing members. Wired to `HubRelayManager.sendCommit`.
28
+ */
29
+ scheduleSendCommit: (groupID: string, commitBytes: Uint8Array) => void;
18
30
  dispose: () => Promise<void>;
19
31
  };
20
32
  export type SetupHubRelayParams = {
@@ -26,7 +38,7 @@ export type SetupHubRelayParams = {
26
38
  * through the engine with `origin: 'peer'`.
27
39
  */
28
40
  graph: GraphInternals;
29
- emitter: GroupEventEmitter;
41
+ emitter: P2PEventEmitter;
30
42
  createHubClient: CreateHubClient;
31
43
  onRemoteBroadcast: (event: BroadcastEvent) => void;
32
44
  /**
package/lib/hub/wiring.js CHANGED
@@ -1 +1 @@
1
- import{BroadcastService as r}from"../groups/broadcast-service.js";import{HubRelayManager as e}from"./relay-manager.js";export function setupHubRelay(o){let a,{identity:t,db:d,graph:i,emitter:s,createHubClient:n,onRemoteBroadcast:p,registry:c,logger:u,createGroupChannel:h,createHubConnection:l,storeUnreadable:g,defaultAccessLevel:f,forwarding:m,onServerDIDObserved:y}=o,b=[],D=(r,e)=>{a.then(o=>o.broadcast(r,e).catch(e=>{u.error("broadcast failed",{groupID:r,error:e})}),()=>{})};return(a=(async()=>{let[o,a]=await Promise.all([d.getStore("p2p"),d.getStore("graph")]),v=new r({p2pStore:o,graphStore:a,graph:i,selfDID:t.id,registry:c,storeUnreadable:g,defaultAccessLevel:f,forwarding:m,scheduleBroadcast:D,logger:u}),B=new e({p2pStore:o,broadcastService:v,deviceID:t.id,createHubClient:n,logger:u,createGroupChannel:h,createHubConnection:l,onServerDIDObserved:y});b.push(v.on("broadcast",p)),b.push(s.on("groupJoined",r=>B.addGroup(r.id).catch(e=>{u.error("addGroup failed",{groupID:r.id,error:e})}))),b.push(s.on("groupLeft",r=>B.removeGroup(r.groupID).catch(e=>{u.error("removeGroup failed",{groupID:r.groupID,error:e})}))),b.push(s.on("hubBound",({groupID:r,hubURL:e})=>B.addBinding(r,e).catch(o=>{u.error("addBinding failed",{groupID:r,hubURL:e,error:o})}))),b.push(s.on("hubUnbound",({groupID:r,hubURL:e})=>B.removeBinding(r,e).catch(o=>{u.error("removeBinding failed",{groupID:r,hubURL:e,error:o})})));let w=await o.listGroups();return await B.start(w.map(r=>r.id)),B})()).catch(r=>{u.error("hub relay setup failed",{error:r})}),{ready:a.then(()=>void 0),scheduleBroadcast:D,dispose:async()=>{for(let r of b)try{r()}catch{}b.length=0;try{let r=await a;await r.stop()}catch{}}}}export const DISABLED_HUB={ready:Promise.resolve(),scheduleBroadcast:()=>{},dispose:async()=>{}};
1
+ import{HLC as e}from"@kubun/hlc";import{BroadcastService as r}from"../groups/broadcast-service.js";import{HubRelayManager as o}from"./relay-manager.js";export function setupHubRelay(a){let t,{identity:d,db:i,graph:n,emitter:s,createHubClient:c,onRemoteBroadcast:u,registry:p,logger:h,createGroupChannel:l,createHubConnection:m,storeUnreadable:g,defaultAccessLevel:f,forwarding:b,onServerDIDObserved:y}=a,w=[],D=(e,r)=>{t.then(o=>o.broadcast(e,r).catch(r=>{h.error("broadcast failed",{groupID:e,error:r})}),()=>{})},v=async(e,r)=>{let o=await t;await o.broadcast(e,r)};return(t=(async()=>{let[a,t]=await Promise.all([i.getStore("p2p"),i.getStore("graph")]),v=new r({p2pStore:a,graphStore:t,graph:n,selfDID:d.id,registry:p,hlc:new e({nodeID:d.id}),storeUnreadable:g,defaultAccessLevel:f,forwarding:b,scheduleBroadcast:D,emitter:s,logger:h}),B=new o({p2pStore:a,broadcastService:v,deviceID:d.id,createHubClient:c,logger:h,createGroupChannel:l,createHubConnection:m,onServerDIDObserved:y});w.push(v.on("broadcast",u)),w.push(s.on("groupJoined",e=>B.addGroup(e.id).catch(r=>{h.error("addGroup failed",{groupID:e.id,error:r})}))),w.push(s.on("groupLeft",e=>B.removeGroup(e.groupID).catch(r=>{h.error("removeGroup failed",{groupID:e.groupID,error:r})}))),w.push(s.on("hubBound",({groupID:e,hubURL:r})=>B.addBinding(e,r).catch(o=>{h.error("addBinding failed",{groupID:e,hubURL:r,error:o})}))),w.push(s.on("hubUnbound",({groupID:e,hubURL:r})=>B.removeBinding(e,r).catch(o=>{h.error("removeBinding failed",{groupID:e,hubURL:r,error:o})})));let I=await a.listGroups();return await B.start(I.map(e=>e.id)),B})()).catch(e=>{h.error("hub relay setup failed",{error:e})}),{ready:t.then(()=>void 0),scheduleBroadcast:D,broadcastNow:v,scheduleSendCommit:(e,r)=>{t.then(o=>o.sendCommit(e,r).catch(r=>{h.error("sendCommit failed",{groupID:e,error:r})}),()=>{})},dispose:async()=>{for(let e of w)try{e()}catch{}w.length=0;try{let e=await t;await e.stop()}catch{}}}}export const DISABLED_HUB={ready:Promise.resolve(),scheduleBroadcast:()=>{},broadcastNow:async()=>{},scheduleSendCommit:()=>{},dispose:async()=>{}};
package/lib/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export type { ContextDeps, PendingJoinRequest } from './context/types.js';
8
8
  export { type AffectedRow, type GroupBroadcastMessage, type MutationApplyEntry, type ProcessBroadcastParams, type ProcessBroadcastResult, processBroadcast, } from './groups/broadcast.js';
9
9
  export { deserializeBroadcast, serializeBroadcast } from './groups/broadcast-codec.js';
10
10
  export { ApplyError, type BroadcastEvent, BroadcastService, type BroadcastServiceEvents, type BroadcastServiceParams, DecryptError, type ReceiveBroadcastParams, type ReceiveBroadcastResult, type SendBroadcastParams, } from './groups/broadcast-service.js';
11
- export { createFilteredGenerator, createGroupEventEmitter, type GroupEventEmitter, type GroupEventMap, } from './groups/events.js';
11
+ export { createFilteredGenerator, createP2PEventEmitter, type P2PEventEmitter, type P2PEventMap, } from './groups/events.js';
12
12
  export { GroupHandleRegistry, type GroupHandleRegistryParams, type SeedParams, } from './groups/group-handle-registry.js';
13
13
  export { decodeFullJoinRequest, decodeInvitePayload, decodeJoinRequest, encodeFullJoinRequest, encodeInvitePayload, encodeJoinRequest, type FullJoinRequestPayload, type InvitePayload, type JoinRequestPayload, type SuggestedHub, } from './groups/invite-payload.js';
14
14
  export { type AddCircleMemberParams, type AddCircleMemberResult, type CreateCircleParams, type CreateCircleResult, type CreateGroupParams, type CreateGroupResult, type DeleteCircleParams, type DeleteCircleResult, GroupManager, type GroupManagerParams, type InviteToGroupParams, type InviteToGroupResult, type JoinGroupParams, type JoinGroupResult, type LeaveGroupParams, type LeaveGroupResult, type RemoveCircleMemberParams, type RemoveCircleMemberResult, type RemoveGroupMemberParams, type RemoveGroupMemberResult, type RemoveMemberParams, type RemoveMemberResult, type UpdateCircleParams, type UpdateCircleResult, type UpdateGroupParams, type UpdateGroupResult, } from './groups/manager.js';
@@ -20,7 +20,6 @@ export type { SyncClientMessage, SyncProtocol, SyncServerMessage } from './proto
20
20
  export { createP2PSchemaExtension } from './schema.js';
21
21
  export { type BroadcastBatchConfig, type BroadcastQueue, type BroadcastQueueParams, createBroadcastQueue, DEFAULT_BROADCAST_BATCH_CONFIG, } from './sync/broadcast-queue.js';
22
22
  export { type BroadcastSenderParams, DEFAULT_PUSH_SYNC_CONFIG, type PushSyncConfig, wireBroadcastSender, } from './sync/broadcast-sender.js';
23
- export { catalogMatchesDoc } from './sync/catalog-match.js';
24
23
  export { type CatalogSyncScope, resolveCatalogSyncScopes } from './sync/catalog-scope.js';
25
24
  export { type EvaluateAndForwardParams, type EvaluateAndForwardResult, evaluateAndForward, type ForwardContext, type ForwardFilter, type ForwardingConfig, } from './sync/forwarder.js';
26
25
  export { checkSyncDelegation, negotiateDirection } from './sync/handlers.js';
package/lib/index.js CHANGED
@@ -1 +1 @@
1
- import{ServerTransport as e}from"@enkaku/http-server-transport";import{Server as r}from"@enkaku/server";import{isFullIdentity as t}from"@enkaku/token";import{DirectTransports as o}from"@enkaku/transport";import{HLC as s}from"@kubun/hlc";import{p2pStoreDefinition as n}from"@kubun/store-p2p";import{createGroupContext as a}from"./context/group.js";import{createHubContext as i}from"./context/hub.js";import{createJoinContext as c}from"./context/join.js";import{createSyncContext as p}from"./context/sync.js";import{createGroupEventEmitter as d}from"./groups/events.js";import{GroupHandleRegistry as u}from"./groups/group-handle-registry.js";import{GroupManager as l}from"./groups/manager.js";import{forwardRemoteBroadcast as g}from"./hub/forward-remote-broadcast.js";import{createHTTPHubClient as m}from"./hub/http-client.js";import{captureServerDID as y}from"./hub/manager.js";import{DISABLED_HUB as b,setupHubRelay as f}from"./hub/wiring.js";import{createP2PSchemaExtension as h}from"./schema.js";import{createBroadcastQueue as j,DEFAULT_BROADCAST_BATCH_CONFIG as v}from"./sync/broadcast-queue.js";import{DEFAULT_PUSH_SYNC_CONFIG as x,wireBroadcastSender as S}from"./sync/broadcast-sender.js";import{createSyncHandlers as w}from"./sync/handlers.js";import{SyncManager as D}from"./sync/sync-manager.js";export{processBroadcast}from"./groups/broadcast.js";export{deserializeBroadcast,serializeBroadcast}from"./groups/broadcast-codec.js";export{ApplyError,BroadcastService,DecryptError}from"./groups/broadcast-service.js";export{createFilteredGenerator,createGroupEventEmitter}from"./groups/events.js";export{decodeFullJoinRequest,decodeInvitePayload,decodeJoinRequest,encodeFullJoinRequest,encodeInvitePayload,encodeJoinRequest}from"./groups/invite-payload.js";export{GroupManager}from"./groups/manager.js";export{MLSEncryptor}from"./groups/mls-encryptor.js";export{restoreMLSGroupHandle}from"./groups/mls-group-handle.js";export{replacer as mlsJSONReplacer,reviver as mlsJSONReviver}from"./groups/mls-json.js";export{deserializeMLSGroupState,serializeMLSGroupState}from"./groups/mls-state.js";export{createP2PSchemaExtension}from"./schema.js";export{createBroadcastQueue,DEFAULT_BROADCAST_BATCH_CONFIG}from"./sync/broadcast-queue.js";export{DEFAULT_PUSH_SYNC_CONFIG,wireBroadcastSender}from"./sync/broadcast-sender.js";export{catalogMatchesDoc}from"./sync/catalog-match.js";export{resolveCatalogSyncScopes}from"./sync/catalog-scope.js";export{evaluateAndForward}from"./sync/forwarder.js";export{checkSyncDelegation,negotiateDirection}from"./sync/handlers.js";export{HubTunnelSyncListener}from"./sync/hub-tunnel-sync-listener.js";export{HubTunnelSyncProvider}from"./sync/hub-tunnel-sync-provider.js";export{PeerRegistry}from"./sync/peer-registry.js";export{SyncClient}from"./sync/sync-client.js";export{SyncManager}from"./sync/sync-manager.js";export{DIDObservingTransport}from"./hub/did-observing-transport.js";export{ReconnectingError}from"./hub/errors.js";export{createHTTPHubClient}from"./hub/http-client.js";export{bindHubToGroup,captureServerDID,createHub,deleteHub,getHub,HubServerDIDConflictError,listGroupsByHub,listHubs,listHubsByGroup,unbindHubFromGroup,updateHub,upsertHub}from"./hub/manager.js";export{HubRelayManager}from"./hub/relay-manager.js";export{DISABLED_HUB,setupHubRelay}from"./hub/wiring.js";export const DEFAULT_RECEIVE_CONFIG={storeUnreadable:"persist"};export function createP2PPlugin(P){return I=>{var R;let E,H,L,B;I.db.register(n);let C=I.db.adapter;if(!(t(R=I.identity)&&"privateKey"in R))throw Error("createP2PPlugin requires an OwnIdentity (full identity with privateKey) — received a narrower Identity");let G=I.identity,A=new s({nodeID:G.id}),F={...DEFAULT_RECEIVE_CONFIG,...P?.receive},k=P?.defaultAccessLevel??{read:"only_owner",write:"only_owner"},T=new u({stores:I.db,deviceID:G.id,logger:I.getLogger("group-handle-registry")}),U=new l({identity:G,getRandomID:I.runtime.getRandomID,registry:T}),_=d(),O=new D({deployClusters:async e=>{let r=Object.values(e);await I.engine.deployGraph({clusters:r})},graph:I.graph,runtime:I.runtime,identity:I.identity,logger:I.getLogger("sync"),storeUnreadable:F.storeUnreadable,defaultAccessLevel:k}),q=w({db:I.db,graph:I.graph,logger:I.getLogger("sync-handlers"),peerRegistry:O.peerRegistry,selfDID:G.id,storeUnreadable:F.storeUnreadable,defaultAccessLevel:k}),M=new Map,N=[],J=I.getLogger("hub-relay"),z=b,K={...x,...P?.pushSync};if(P?.hub){let e="object"==typeof P.hub&&null!=P.hub.createHubClient?P.hub.createHubClient:m({identity:G,fetch:I.runtime.fetch});z=f({identity:G,db:I.db,graph:I.graph,emitter:_,createHubClient:e,registry:T,onRemoteBroadcast:e=>{g(_,e,J).catch(e=>{J.error("forwardRemoteBroadcast failed",{error:e})})},logger:J,storeUnreadable:F.storeUnreadable,defaultAccessLevel:k,forwarding:P.forwarding,onServerDIDObserved:({hubURL:e,serverDID:r})=>y({stores:I.db,hubURL:e,serverDID:r}).then(()=>void 0)}),K.enabled&&(H=j({config:{...v,...P.batch},scheduleBroadcast:z.scheduleBroadcast,logger:I.getLogger("broadcast-queue")}),E=S({db:I.db,eventBus:I.eventBus,selfDID:G.id,queue:H,logger:I.getLogger("broadcast-sender")}))}let V={hubReady:z.ready,addPeer:e=>O.addPeer({config:e,stores:I.db}),removePeer:e=>O.removePeer({peerDID:e,stores:I.db}),listPeers:()=>O.listPeers({stores:I.db}),syncWithPeer:e=>O.merkleSyncWithPeer({...e,stores:I.db}),getStatus:e=>O.getStatus({peerDID:e,stores:I.db}),onSyncEvent:e=>O.onSyncEvent(e),createSyncTransport:function(e){let t=new o({signal:e}),s=new r({getRandomID:I.runtime.getRandomID,handlers:q,logger:I.getLogger("sync-server"),transports:[t.server],signal:e});return N.push(s),t.client}};if(P?.http){let t="string"==typeof P.http?P.http.replace(/^\//,""):"sync";V.syncReady=(async()=>{let o=await I.engine.getAPI("http");await o.listening,L=new e({allowedOrigin:["*"]}),B=new r({getRandomID:I.runtime.getRandomID,handlers:q,logger:I.getLogger("sync-http-server"),transports:[L]}),N.push(B),o.registerProtocol(t,L.fetch.bind(L))})()}return{name:"p2p",schemaExtension:e=>h(_),api:V,createContextFactory:()=>(e,r)=>{let t={identity:G,groupManager:U,registry:T,syncManager:O,stores:r,adapter:C,hlc:A,emitter:_,pendingJoinRequests:M,runtime:I.runtime,autoAcceptPeers:P?.autoAcceptPeers,scheduleBroadcast:z.scheduleBroadcast,logger:I.getLogger("context")};return{group:a(e,t),sync:p(e,t),join:c(e,t),hub:i(e,t)}},dispose:async()=>{if(null!=E)try{E()}catch{}if(null!=H)try{H.dispose()}catch{}let e=(async()=>{for(let e of N)await e.dispose();null!=L&&await L.dispose()})(),r=I.getLogger("dispose"),t=await Promise.allSettled([e,O.dispose(),z.dispose()]),o=["sync-http","sync-manager","hub"];t.forEach((e,t)=>{"rejected"===e.status&&r.error("dispose branch failed",{branch:o[t],error:e.reason})})}}}}
1
+ import{ServerTransport as e}from"@enkaku/http-server-transport";import{Server as r}from"@enkaku/server";import{isFullIdentity as t}from"@enkaku/token";import{DirectTransports as o}from"@enkaku/transport";import{HLC as s}from"@kubun/hlc";import{p2pStoreDefinition as n}from"@kubun/store-p2p";import{createDelegationContext as a}from"./context/delegation.js";import{createGroupContext as i}from"./context/group.js";import{createHubContext as c}from"./context/hub.js";import{createJoinContext as p}from"./context/join.js";import{createSyncContext as d}from"./context/sync.js";import{createP2PEventEmitter as u}from"./groups/events.js";import{GroupHandleRegistry as l}from"./groups/group-handle-registry.js";import{GroupManager as g}from"./groups/manager.js";import{forwardRemoteBroadcast as m}from"./hub/forward-remote-broadcast.js";import{createHTTPHubClient as b}from"./hub/http-client.js";import{captureServerDID as y}from"./hub/manager.js";import{DISABLED_HUB as f,setupHubRelay as h}from"./hub/wiring.js";import{createP2PSchemaExtension as j}from"./schema.js";import{createBroadcastQueue as v,DEFAULT_BROADCAST_BATCH_CONFIG as x}from"./sync/broadcast-queue.js";import{DEFAULT_PUSH_SYNC_CONFIG as S,wireBroadcastSender as w}from"./sync/broadcast-sender.js";import{createSyncHandlers as D}from"./sync/handlers.js";import{SyncManager as P}from"./sync/sync-manager.js";export{processBroadcast}from"./groups/broadcast.js";export{deserializeBroadcast,serializeBroadcast}from"./groups/broadcast-codec.js";export{ApplyError,BroadcastService,DecryptError}from"./groups/broadcast-service.js";export{createFilteredGenerator,createP2PEventEmitter}from"./groups/events.js";export{GroupHandleRegistry}from"./groups/group-handle-registry.js";export{decodeFullJoinRequest,decodeInvitePayload,decodeJoinRequest,encodeFullJoinRequest,encodeInvitePayload,encodeJoinRequest}from"./groups/invite-payload.js";export{GroupManager}from"./groups/manager.js";export{MLSEncryptor}from"./groups/mls-encryptor.js";export{restoreMLSGroupHandle}from"./groups/mls-group-handle.js";export{replacer as mlsJSONReplacer,reviver as mlsJSONReviver}from"./groups/mls-json.js";export{deserializeMLSGroupState,serializeMLSGroupState}from"./groups/mls-state.js";export{createP2PSchemaExtension}from"./schema.js";export{createBroadcastQueue,DEFAULT_BROADCAST_BATCH_CONFIG}from"./sync/broadcast-queue.js";export{DEFAULT_PUSH_SYNC_CONFIG,wireBroadcastSender}from"./sync/broadcast-sender.js";export{resolveCatalogSyncScopes}from"./sync/catalog-scope.js";export{evaluateAndForward}from"./sync/forwarder.js";export{checkSyncDelegation,negotiateDirection}from"./sync/handlers.js";export{HubTunnelSyncListener}from"./sync/hub-tunnel-sync-listener.js";export{HubTunnelSyncProvider}from"./sync/hub-tunnel-sync-provider.js";export{PeerRegistry}from"./sync/peer-registry.js";export{SyncClient}from"./sync/sync-client.js";export{SyncManager}from"./sync/sync-manager.js";export{DIDObservingTransport}from"./hub/did-observing-transport.js";export{ReconnectingError}from"./hub/errors.js";export{createHTTPHubClient}from"./hub/http-client.js";export{bindHubToGroup,captureServerDID,createHub,deleteHub,getHub,HubServerDIDConflictError,listGroupsByHub,listHubs,listHubsByGroup,unbindHubFromGroup,updateHub,upsertHub}from"./hub/manager.js";export{HubRelayManager}from"./hub/relay-manager.js";export{DISABLED_HUB,setupHubRelay}from"./hub/wiring.js";export const DEFAULT_RECEIVE_CONFIG={storeUnreadable:"persist"};export function createP2PPlugin(I){return R=>{var E;let H,L,B,C;R.db.register(n);let G=R.db.adapter;if(!(t(E=R.identity)&&"privateKey"in E))throw Error("createP2PPlugin requires an OwnIdentity (full identity with privateKey) — received a narrower Identity");let A=R.identity,F=new s({nodeID:A.id}),k={...DEFAULT_RECEIVE_CONFIG,...I?.receive},T=I?.defaultAccessLevel??{read:"only_owner",write:"only_owner"},U=new l({stores:R.db,deviceID:A.id,logger:R.getLogger("group-handle-registry")}),_=new g({identity:A,getRandomID:R.runtime.getRandomID,registry:U}),O=u(),q=new P({deployClusters:async e=>{let r=Object.values(e);await R.engine.deployGraph({clusters:r})},graph:R.graph,runtime:R.runtime,identity:R.identity,logger:R.getLogger("sync"),storeUnreadable:k.storeUnreadable,defaultAccessLevel:T}),N=D({db:R.db,graph:R.graph,logger:R.getLogger("sync-handlers"),peerRegistry:q.peerRegistry,selfDID:A.id,storeUnreadable:k.storeUnreadable,defaultAccessLevel:T}),M=new Map,J=[],z=R.getLogger("hub-relay"),K=f,V={...S,...I?.pushSync};if(I?.hub){let e="object"==typeof I.hub&&null!=I.hub.createHubClient?I.hub.createHubClient:b({identity:A,fetch:R.runtime.fetch});K=h({identity:A,db:R.db,graph:R.graph,emitter:O,createHubClient:e,registry:U,onRemoteBroadcast:e=>{m(O,e,z).catch(e=>{z.error("forwardRemoteBroadcast failed",{error:e})})},logger:z,storeUnreadable:k.storeUnreadable,defaultAccessLevel:T,forwarding:I.forwarding,onServerDIDObserved:({hubURL:e,serverDID:r})=>y({stores:R.db,hubURL:e,serverDID:r}).then(()=>void 0)}),V.enabled&&(L=v({config:{...x,...I.batch},scheduleBroadcast:K.scheduleBroadcast,logger:R.getLogger("broadcast-queue")}),H=w({db:R.db,eventBus:R.eventBus,selfDID:A.id,queue:L,logger:R.getLogger("broadcast-sender")}))}let W={hubReady:K.ready,addPeer:e=>q.addPeer({config:e,stores:R.db}),removePeer:e=>q.removePeer({peerDID:e,stores:R.db}),listPeers:()=>q.listPeers({stores:R.db}),syncWithPeer:e=>q.merkleSyncWithPeer({...e,stores:R.db}),getStatus:e=>q.getStatus({peerDID:e,stores:R.db}),onSyncEvent:e=>q.onSyncEvent(e),createSyncTransport:function(e){let t=new o({signal:e}),s=new r({getRandomID:R.runtime.getRandomID,handlers:N,logger:R.getLogger("sync-server"),transports:[t.server],signal:e});return J.push(s),t.client}};if(I?.http){let t="string"==typeof I.http?I.http.replace(/^\//,""):"sync";W.syncReady=(async()=>{let o=await R.engine.getAPI("http");await o.listening,B=new e({allowedOrigin:["*"]}),C=new r({getRandomID:R.runtime.getRandomID,handlers:N,logger:R.getLogger("sync-http-server"),transports:[B]}),J.push(C),o.registerProtocol(t,B.fetch.bind(B))})()}return{name:"p2p",schemaExtension:e=>j(O),api:W,createContextFactory:()=>(e,r)=>{let t={identity:A,groupManager:_,registry:U,syncManager:q,stores:r,adapter:G,hlc:F,emitter:O,pendingJoinRequests:M,runtime:R.runtime,autoAcceptPeers:I?.autoAcceptPeers,scheduleBroadcast:K.scheduleBroadcast,broadcastNow:K.broadcastNow,scheduleSendCommit:K.scheduleSendCommit,logger:R.getLogger("context")};return{group:i(e,t),sync:d(e,t),join:p(e,t),hub:c(e,t),delegation:a(e,t)}},dispose:async()=>{if(null!=H)try{H()}catch{}if(null!=L)try{L.dispose()}catch{}let e=(async()=>{for(let e of J)await e.dispose();null!=B&&await B.dispose()})(),r=R.getLogger("dispose"),t=await Promise.allSettled([e,q.dispose(),K.dispose()]),o=["sync-http","sync-manager","hub"];t.forEach((e,t)=>{"rejected"===e.status&&r.error("dispose branch failed",{branch:o[t],error:e.reason})})}}}}
package/lib/schema.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import type { SchemaExtension } from '@kubun/engine';
2
- import { type GroupEventEmitter } from './groups/events.js';
3
- export declare function createP2PSchemaExtension(emitter: GroupEventEmitter): SchemaExtension;
2
+ import { type P2PEventEmitter } from './groups/events.js';
3
+ export declare function createP2PSchemaExtension(emitter: P2PEventEmitter): SchemaExtension;
package/lib/schema.js CHANGED
@@ -1,4 +1,4 @@
1
- import{createFilteredGenerator as e}from"./groups/events.js";function r(e){if(null==e.p2p)throw Error("p2p plugin is not wired into this context");return e.p2p}let t=`
1
+ import{createFilteredGenerator as e}from"./groups/events.js";function r(e){if(null==e.p2p)throw Error("p2p plugin is not wired into this context");return e.p2p}async function*t(){}let o=`
2
2
  type PeerGroup {
3
3
  id: ID!
4
4
  name: String!
@@ -173,6 +173,44 @@ type DeleteHubPayload {
173
173
  boundGroupIDs: [ID!]!
174
174
  }
175
175
 
176
+ type DelegationToken {
177
+ jwt: String!
178
+ jti: String!
179
+ grantor: ID!
180
+ audience: ID!
181
+ resource: String!
182
+ exp: Int!
183
+ }
184
+
185
+ input DelegationTokenCovers {
186
+ modelID: ID
187
+ docID: ID
188
+ }
189
+
190
+ """
191
+ A verified revocation of a delegation token addressed to the viewer.
192
+ Only surfaces while the underlying held capability still exists locally;
193
+ revocations whose cap was deleted or never arrived are excluded by the
194
+ audience-derivation join.
195
+ """
196
+ type RevokedDelegationToken {
197
+ jti: ID!
198
+ grantor: ID!
199
+ audience: ID!
200
+ revokerDID: DID!
201
+ "Issuer's revocation-record timestamp (iat), Unix epoch seconds."
202
+ revokedAt: Int!
203
+ """
204
+ Wall-clock time the revocation became binding, Unix epoch seconds. For
205
+ self-issued revocations this equals revokedAt (verified at mint time);
206
+ for received revocations it is the receiver's clock at the moment the
207
+ cap-vs-revoker cross-check succeeded.
208
+ """
209
+ verifiedAt: Int!
210
+ capExp: Int
211
+ groupID: ID!
212
+ }
213
+
176
214
  extend type Query {
177
215
  groups: [PeerGroup!]!
178
216
  group(id: ID!): PeerGroup
@@ -182,6 +220,8 @@ extend type Query {
182
220
  hub(id: ID!): PeerHub
183
221
  syncPeers: [SyncPeer!]!
184
222
  syncStatus: SyncStatus!
223
+ ownDelegationTokens(grantor: ID, covers: DelegationTokenCovers): [DelegationToken!]!
224
+ ownRevokedDelegationTokens(grantor: ID): [RevokedDelegationToken!]!
185
225
  }
186
226
 
187
227
  extend type Mutation {
@@ -190,7 +230,7 @@ extend type Mutation {
190
230
  createCircle(groupID: ID!, name: String!, description: String): PeerCircle!
191
231
  addCircleMember(circleID: ID!, memberDID: DID!, role: String!): PeerCircleMember!
192
232
  createCatalog(name: String!, description: String, filterCriteria: String!): PeerCatalog!
193
- inviteToGroup(groupID: ID!, joinRequest: String!): InviteToGroupPayload!
233
+ inviteToGroup(groupID: ID!, joinRequest: String!, grants: [String!], revocations: [String!]): InviteToGroupPayload!
194
234
  joinGroup(invitePayload: String!, joinRequestPayload: String!): JoinGroupPayload!
195
235
  prepareJoinRequest: PrepareJoinRequestPayload!
196
236
  completeJoin(invitePayload: String!): CompleteJoinPayload!
@@ -202,6 +242,8 @@ extend type Mutation {
202
242
  deleteCatalog(catalogID: ID!): DeleteCatalogPayload!
203
243
  deleteCircle(circleID: ID!): DeleteCirclePayload!
204
244
  removeCircleMember(circleID: ID!, memberDID: DID!): RemoveCircleMemberPayload!
245
+ grantWriteCapability(to: DID!, res: String!, groupID: ID!, expiresIn: Int): String!
246
+ revokeCapability(jti: ID!): Boolean!
205
247
  addSyncPeer(peerDID: DID!, endpoint: URL!, mode: String, priority: String, trustLevel: String): Boolean!
206
248
  removeSyncPeer(peerDID: DID!): RemoveSyncPeerPayload!
207
249
  createHub(input: CreateHubInput!): PeerHub!
@@ -231,5 +273,7 @@ extend type Subscription {
231
273
  circleMemberAdded(circleID: ID!): PeerCircleMember!
232
274
  circleMemberRemoved(circleID: ID!): PeerCircleMember!
233
275
  circleCatalogsChanged(circleID: ID!): PeerCircle!
276
+ ownDelegationTokenAdded: DelegationToken!
277
+ ownDelegationTokenRevoked: RevokedDelegationToken!
234
278
  }
235
- `;export function createP2PSchemaExtension(a){return{sdl:t,resolvers:{queryFields:{groups:async(e,t,a)=>await r(a).group.list(),group:async(e,t,a)=>await r(a).group.get(t.id),groupMembers:async(e,t,a)=>await r(a).group.listMembers(t.groupID),circles:async(e,t,a)=>await r(a).group.listCircles(t.groupID),hubs:async(e,t,a)=>await r(a).hub.list(),hub:async(e,t,a)=>await r(a).hub.get(t.id),syncPeers:async(e,t,a)=>await r(a).sync.listPeers(),syncStatus:(e,t,a)=>{let o=r(a).sync.getStatus();return{...o,lastSyncByPeer:JSON.stringify(o.lastSyncByPeer)}}},mutationFields:{createGroup:async(e,t,a)=>{let o=t.input;return{group:await r(a).group.create(o)}},addGroupMember:async(e,t,a)=>await r(a).group.addMember(t.groupID,t.memberDID,t.role),createCircle:async(e,t,a)=>await r(a).group.createCircle({groupID:t.groupID,name:t.name,description:t.description}),addCircleMember:async(e,t,a)=>await r(a).group.addCircleMember(t.circleID,t.memberDID,t.role),createCatalog:async(e,t,a)=>await r(a).group.createCatalog({name:t.name,description:t.description,filterCriteria:t.filterCriteria}),inviteToGroup:async(e,t,a)=>await r(a).group.invite(t.groupID,t.joinRequest),joinGroup:async(e,t,a)=>await r(a).group.join(t.invitePayload,t.joinRequestPayload),prepareJoinRequest:async(e,t,a)=>await r(a).join.prepareRequest(),completeJoin:async(e,t,a)=>await r(a).join.complete(t.invitePayload),leaveGroup:async(e,t,a)=>await r(a).group.leave(t.groupID),removeGroupMember:async(e,t,a)=>await r(a).group.removeMember(t.groupID,t.memberDID),updateCatalog:async(e,t,a)=>{let o=t.input;return await r(a).group.updateCatalog(o)},updateGroup:async(e,t,a)=>{let o=t.input;return{group:await r(a).group.update(o)}},updateCircle:async(e,t,a)=>{let o=t.input;return{circle:await r(a).group.updateCircle(o)}},deleteCatalog:async(e,t,a)=>await r(a).group.deleteCatalog(t.catalogID),deleteCircle:async(e,t,a)=>await r(a).group.deleteCircle(t.circleID),removeCircleMember:async(e,t,a)=>await r(a).group.removeCircleMember(t.circleID,t.memberDID),addSyncPeer:async(e,t,a)=>(await r(a).sync.addPeer({peerDID:t.peerDID,endpoint:t.endpoint,mode:t.mode??"on-demand",allowedUsers:{all:!0},priority:t.priority??"0",trustLevel:t.trustLevel??"trusted"}),!0),removeSyncPeer:async(e,t,a)=>(await r(a).sync.removePeer(t.peerDID),{success:!0}),createHub:async(e,t,a)=>{let o=t.input;return await r(a).hub.create(o)},updateHub:async(e,t,a)=>{let o=t.input;return await r(a).hub.update(o)},deleteHub:async(e,t,a)=>await r(a).hub.delete(t.id,t.force??!1),bindHubToGroup:async(e,t,a)=>await r(a).hub.bind({hubID:t.hubID,groupID:t.groupID}),unbindHubFromGroup:async(e,t,a)=>await r(a).hub.unbind({hubID:t.hubID,groupID:t.groupID})},typeFields:{PeerGroup:{hubs:async(e,t,a)=>await r(a).hub.listByGroup(e.id)},PeerHub:{groups:async(e,t,a)=>await r(a).hub.listGroups(e.id)}},subscriptionFields:{groupJoined:{resolve:e=>e,subscribe:()=>e(a,"groupJoined")},groupLeft:{resolve:e=>e,subscribe:()=>e(a,"groupLeft")},groupDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(a,"groupDataChanged",e=>e.groupID===t.groupID)},groupMemberJoined:{resolve:e=>e,subscribe:(r,t)=>e(a,"groupMemberJoined",e=>e.groupID===t.groupID)},groupMemberLeft:{resolve:e=>e,subscribe:(r,t)=>e(a,"groupMemberLeft",e=>e.groupID===t.groupID)},circleCreated:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleCreated",e=>e.groupID===t.groupID)},circleDeleted:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleDeleted",e=>e.groupID===t.groupID)},circleDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleDataChanged",e=>e.circleID===t.circleID)},circleMemberAdded:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleMemberAdded",e=>e.circleID===t.circleID)},circleMemberRemoved:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleMemberRemoved",e=>e.circleID===t.circleID)},circleCatalogsChanged:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleCatalogsChanged",e=>e.circleID===t.circleID)}}}}}
279
+ `;export function createP2PSchemaExtension(i){return{sdl:o,resolvers:{queryFields:{groups:async(e,t,o)=>await r(o).group.list(),group:async(e,t,o)=>await r(o).group.get(t.id),groupMembers:async(e,t,o)=>await r(o).group.listMembers(t.groupID),circles:async(e,t,o)=>await r(o).group.listCircles(t.groupID),hubs:async(e,t,o)=>await r(o).hub.list(),hub:async(e,t,o)=>await r(o).hub.get(t.id),syncPeers:async(e,t,o)=>await r(o).sync.listPeers(),syncStatus:(e,t,o)=>{let i=r(o).sync.getStatus();return{...i,lastSyncByPeer:JSON.stringify(i.lastSyncByPeer)}},ownDelegationTokens:async(e,r,t)=>{let o=t.getViewer();if(null==o||null==t.p2p)return[];let i=r.grantor??null,a=r.covers??null;return await t.p2p.delegation.listOwnTokens({viewerDID:o,grantor:i,covers:a})},ownRevokedDelegationTokens:async(e,r,t)=>{let o=t.getViewer();if(null==o||null==t.p2p)return[];let i=r.grantor??null;return await t.p2p.delegation.listOwnRevokedTokens({viewerDID:o,grantor:i})}},mutationFields:{createGroup:async(e,t,o)=>{let i=t.input;return{group:await r(o).group.create(i)}},addGroupMember:async(e,t,o)=>await r(o).group.addMember(t.groupID,t.memberDID,t.role),createCircle:async(e,t,o)=>await r(o).group.createCircle({groupID:t.groupID,name:t.name,description:t.description}),addCircleMember:async(e,t,o)=>await r(o).group.addCircleMember(t.circleID,t.memberDID,t.role),createCatalog:async(e,t,o)=>await r(o).group.createCatalog({name:t.name,description:t.description,filterCriteria:t.filterCriteria}),inviteToGroup:async(e,t,o)=>await r(o).group.invite(t.groupID,t.joinRequest,t.grants,t.revocations),joinGroup:async(e,t,o)=>await r(o).group.join(t.invitePayload,t.joinRequestPayload),prepareJoinRequest:async(e,t,o)=>await r(o).join.prepareRequest(),completeJoin:async(e,t,o)=>await r(o).join.complete(t.invitePayload),leaveGroup:async(e,t,o)=>await r(o).group.leave(t.groupID),removeGroupMember:async(e,t,o)=>await r(o).group.removeMember(t.groupID,t.memberDID),updateCatalog:async(e,t,o)=>{let i=t.input;return await r(o).group.updateCatalog(i)},updateGroup:async(e,t,o)=>{let i=t.input;return{group:await r(o).group.update(i)}},updateCircle:async(e,t,o)=>{let i=t.input;return{circle:await r(o).group.updateCircle(i)}},deleteCatalog:async(e,t,o)=>await r(o).group.deleteCatalog(t.catalogID),deleteCircle:async(e,t,o)=>await r(o).group.deleteCircle(t.circleID),removeCircleMember:async(e,t,o)=>await r(o).group.removeCircleMember(t.circleID,t.memberDID),grantWriteCapability:async(e,t,o)=>await r(o).group.grantWriteCapability({to:t.to,res:t.res,groupID:t.groupID,expiresIn:t.expiresIn}),revokeCapability:async(e,t,o)=>await r(o).group.revokeCapability({jti:t.jti}),addSyncPeer:async(e,t,o)=>(await r(o).sync.addPeer({peerDID:t.peerDID,endpoint:t.endpoint,mode:t.mode??"on-demand",allowedUsers:{all:!0},priority:t.priority??"0",trustLevel:t.trustLevel??"trusted"}),!0),removeSyncPeer:async(e,t,o)=>(await r(o).sync.removePeer(t.peerDID),{success:!0}),createHub:async(e,t,o)=>{let i=t.input;return await r(o).hub.create(i)},updateHub:async(e,t,o)=>{let i=t.input;return await r(o).hub.update(i)},deleteHub:async(e,t,o)=>await r(o).hub.delete(t.id,t.force??!1),bindHubToGroup:async(e,t,o)=>await r(o).hub.bind({hubID:t.hubID,groupID:t.groupID}),unbindHubFromGroup:async(e,t,o)=>await r(o).hub.unbind({hubID:t.hubID,groupID:t.groupID})},typeFields:{PeerGroup:{hubs:async(e,t,o)=>await r(o).hub.listByGroup(e.id)},PeerHub:{groups:async(e,t,o)=>await r(o).hub.listGroups(e.id)}},subscriptionFields:{groupJoined:{resolve:e=>e,subscribe:()=>e(i,"groupJoined")},groupLeft:{resolve:e=>e,subscribe:()=>e(i,"groupLeft")},groupDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(i,"groupDataChanged",e=>e.groupID===t.groupID)},groupMemberJoined:{resolve:e=>e,subscribe:(r,t)=>e(i,"groupMemberJoined",e=>e.groupID===t.groupID)},groupMemberLeft:{resolve:e=>e,subscribe:(r,t)=>e(i,"groupMemberLeft",e=>e.groupID===t.groupID)},circleCreated:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleCreated",e=>e.groupID===t.groupID)},circleDeleted:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleDeleted",e=>e.groupID===t.groupID)},circleDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleDataChanged",e=>e.circleID===t.circleID)},circleMemberAdded:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleMemberAdded",e=>e.circleID===t.circleID)},circleMemberRemoved:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleMemberRemoved",e=>e.circleID===t.circleID)},circleCatalogsChanged:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleCatalogsChanged",e=>e.circleID===t.circleID)},ownDelegationTokenAdded:{resolve:e=>e,subscribe:(r,o,a)=>{let n=a.getViewer();return null==n?t():e(i,"delegationTokenAdded",e=>e.audience===n)}},ownDelegationTokenRevoked:{resolve:e=>e,subscribe:(r,o,a)=>{let n=a.getViewer();return null==n?t():e(i,"delegationTokenRevoked",e=>e.audience===n)}}}}}}
@@ -1 +1 @@
1
- import{resolveAccessRule as e}from"@kubun/engine";import{DocumentID as r}from"@kubun/id";import{getGraphStore as o}from"@kubun/store-graph";import{getP2PStore as t}from"@kubun/store-p2p";import{catalogMatchesDoc as l}from"./catalog-match.js";import{computeBroadcastScopes as a}from"./scope-resolver.js";export const DEFAULT_PUSH_SYNC_CONFIG={enabled:!1};let n={read:"only_owner",write:"only_owner"};export function wireBroadcastSender(e){let r=e.defaultAccessLevel??n;return e.eventBus.on("engine:mutation:authored",async o=>{try{await i(o,{...e,defaultAccessLevel:r})}catch(r){e.logger?.warn("broadcast-sender handler failed",{documentID:o.documentID,error:String(r)})}})}async function i(n,i){let s=await o(i.db),u=await t(i.db),d=await s.getDocument(r.fromString(n.documentID));if(null==d)return;let f=await e(d,d.model,d.owner,"read",{getUserModelAccessDefault:(e,r,o)=>s.getUserModelAccessDefault(e,r,o),isMemberOfAnyCircle:(e,r)=>u.isMemberOfAnyCircle(e,r),isMemberOfAnyGroup:(e,r)=>u.isMemberOfAnyGroup(e,r)},i.defaultAccessLevel);if("only_owner"===f.level)return;let c=await u.getCirclesForMember(i.selfDID),m=new Set;for(let e of c)for(let r of e.catalog_ids??[])m.add(r);let w=m.size>0?await s.getCatalogs(Array.from(m)):new Map,g=[];for(let e of c)for(let r of e.catalog_ids??[]){let o=w.get(r);null!=o&&g.push({groupID:e.group_id,criteria:o.filter_criteria})}let D=await Promise.all(g.map(async e=>({groupID:e.groupID,matched:await l(e.criteria,d,u)}))),p=new Set;for(let e of D)e.matched&&p.add(e.groupID);let I=Array.from(p);if(0===I.length)return;let b=new Set(f.allowedGroups??[]),v=new Set;if(null!=f.allowedCircles&&f.allowedCircles.length>0)for(let e of(await u.getCircles(f.allowedCircles)).values())null!=e.group_id&&v.add(e.group_id);let y=new Set;if(null!=f.allowedDIDs&&f.allowedDIDs.length>0)for(let e of(await u.getGroupsForMembers(f.allowedDIDs)).values())for(let r of e)y.add(r.id);let h=a({resolvedRule:{level:f.level,allowedGroupIDs:b,allowedCircleParentGroupIDs:v,allowedDIDGroupIDs:y},senderGroupIDs:I});if(0!==h.length)for(let e of h){if(!await u.recordBroadcast({docID:n.documentID,version:n.version,mlsGroupID:e}))continue;let r={mutationJWT:n.mutationJWT,docID:n.documentID,version:n.version,modelID:n.modelID};i.queue.enqueue(e,r,i.selfDID)}}
1
+ import{catalogMatchesDoc as e,resolveAccessRule as r}from"@kubun/engine";import{DocumentID as t}from"@kubun/id";import{getGraphStore as o}from"@kubun/store-graph";import{getP2PStore as l}from"@kubun/store-p2p";import{computeBroadcastScopes as a}from"./scope-resolver.js";export const DEFAULT_PUSH_SYNC_CONFIG={enabled:!1};let n={read:"only_owner",write:"only_owner"};export function wireBroadcastSender(e){let r=e.defaultAccessLevel??n;return e.eventBus.on("engine:mutation:authored",async t=>{try{await i(t,{...e,defaultAccessLevel:r})}catch(r){e.logger?.warn("broadcast-sender handler failed",{documentID:t.documentID,error:String(r)})}})}async function i(n,i){let s=await o(i.db),d=await l(i.db),u=await s.getDocument(t.fromString(n.documentID));if(null==u)return;let f=await r(u,u.model,u.owner,"read",{getUserModelAccessDefault:(e,r,t)=>s.getUserModelAccessDefault(e,r,t),isMemberOfAnyCircle:(e,r)=>d.isMemberOfAnyCircle(e,r),isMemberOfAnyGroup:(e,r)=>d.isMemberOfAnyGroup(e,r),getModelInterfaces:e=>s.getModelInterfaces(e)},i.defaultAccessLevel);if("only_owner"===f.level)return;let c=await d.getCirclesForMember(i.selfDID),m=new Set;for(let e of c)for(let r of e.catalog_ids??[])m.add(r);let w=m.size>0?await s.getCatalogs(Array.from(m)):new Map,g=[];for(let e of c)for(let r of e.catalog_ids??[]){let t=w.get(r);null!=t&&g.push({groupID:e.group_id,criteria:t.filter_criteria})}let D=await Promise.all(g.map(async r=>({groupID:r.groupID,matched:await e(r.criteria,u,d)}))),p=new Set;for(let e of D)e.matched&&p.add(e.groupID);let I=Array.from(p);if(0===I.length)return;let b=new Set(f.allowedGroups??[]),v=new Set;if(null!=f.allowedCircles&&f.allowedCircles.length>0)for(let e of(await d.getCircles(f.allowedCircles)).values())null!=e.group_id&&v.add(e.group_id);let y=new Set;if(null!=f.allowedDIDs&&f.allowedDIDs.length>0)for(let e of(await d.getGroupsForMembers(f.allowedDIDs)).values())for(let r of e)y.add(r.id);let _=a({resolvedRule:{level:f.level,allowedGroupIDs:b,allowedCircleParentGroupIDs:v,allowedDIDGroupIDs:y},senderGroupIDs:I});if(0!==_.length)for(let e of _){if(!await d.recordBroadcast({docID:n.documentID,version:n.version,mlsGroupID:e}))continue;let r={mutationJWT:n.mutationJWT,docID:n.documentID,version:n.version,modelID:n.modelID};i.queue.enqueue(e,r,i.selfDID)}}
@@ -4,6 +4,7 @@ import type { DocumentNode } from '@kubun/protocol';
4
4
  import type { GraphStoreAPI } from '@kubun/store-graph';
5
5
  import type { P2PStoreAPI } from '@kubun/store-p2p';
6
6
  import type { GroupBroadcastMessage, MutationApplyEntry } from '../groups/broadcast.js';
7
+ export declare function rethrowIfProgrammerError(error: unknown): void;
7
8
  /**
8
9
  * Context passed to a {@link ForwardFilter} for each candidate forwarding scope.
9
10
  *
@@ -1 +1 @@
1
- import{resolveAccessRule as e}from"@kubun/engine";import{catalogMatchesDoc as r}from"./catalog-match.js";import{computeBroadcastScopes as o}from"./scope-resolver.js";export async function evaluateAndForward(t){let l,a;if(!1===t.forwarding)return{forwarded:0,skipped:0};let i=!0===t.forwarding?()=>!0:t.forwarding,s=(await t.p2pStore.getGroupsForMember(t.selfDID)).map(e=>e.id).filter(e=>e!==t.sourceGroupID);if(0===s.length)return{forwarded:0,skipped:0};try{l=await e(t.postState,t.postState.model,t.postState.owner,"read",{getUserModelAccessDefault:(e,r,o)=>t.graphStore.getUserModelAccessDefault(e,r,o),isMemberOfAnyCircle:(e,r)=>t.p2pStore.isMemberOfAnyCircle(e,r),isMemberOfAnyGroup:(e,r)=>t.p2pStore.isMemberOfAnyGroup(e,r)},t.defaultAccessLevel)}catch(e){return t.logger?.warn("forwarder: resolveAccessRule failed, skipping forward",{docID:t.entry.docID,version:t.entry.version,sourceGroupID:t.sourceGroupID,error:String(e)}),{forwarded:0,skipped:0}}let n=new Set(l.allowedGroups??[]),d=new Set;if(null!=l.allowedCircles&&l.allowedCircles.length>0)try{for(let e of(await t.p2pStore.getCircles(l.allowedCircles)).values())null!=e.group_id&&d.add(e.group_id)}catch(e){t.logger?.warn("forwarder: getCircles failed, skipping allowedCircles",{count:l.allowedCircles.length,error:String(e)})}let p=new Set;if(null!=l.allowedDIDs&&l.allowedDIDs.length>0)try{for(let e of(await t.p2pStore.getGroupsForMembers(l.allowedDIDs)).values())for(let r of e)p.add(r.id)}catch(e){t.logger?.warn("forwarder: getGroupsForMembers failed, skipping allowedDIDs",{count:l.allowedDIDs.length,error:String(e)})}let f=o({resolvedRule:{level:l.level,allowedGroupIDs:n,allowedCircleParentGroupIDs:d,allowedDIDGroupIDs:p},senderGroupIDs:s});if(0===f.length)return{forwarded:0,skipped:0};try{a=await t.p2pStore.getCirclesForMember(t.selfDID)}catch(e){return t.logger?.warn("forwarder: getCirclesForMember failed",{selfDID:t.selfDID,error:String(e)}),{forwarded:0,skipped:0}}let c=new Set(f),u=new Map;for(let e of a){if(!c.has(e.group_id))continue;let r=u.get(e.group_id);null!=r?r.push(e):u.set(e.group_id,[e])}let g=new Set;for(let e of u.values())for(let r of e)for(let e of r.catalog_ids??[])g.add(e);let w=new Map;if(g.size>0)try{w=await t.graphStore.getCatalogs(Array.from(g))}catch(e){t.logger?.warn("forwarder: getCatalogs failed, treating all as missing",{count:g.size,error:String(e)})}let D=0,I=0;for(let e of f)try{let o=u.get(e)??[],l=!1;for(let e of o){for(let o of e.catalog_ids??[]){let e=w.get(o);if(null!=e&&await r(e.filter_criteria,t.postState,t.p2pStore)){l=!0;break}}if(l)break}if(!l){I++;continue}let a={docID:t.entry.docID,modelID:t.entry.modelID,ownerDID:t.postState.owner,sourceGroupID:t.sourceGroupID,candidateGroupID:e,postState:t.postState};if(!await i(a)){I++;continue}if(!await t.p2pStore.recordBroadcast({docID:t.entry.docID,version:t.entry.version,mlsGroupID:e}))continue;let s={type:"mutation:apply",entries:[t.entry],senderPeerDID:t.selfDID};await t.scheduleBroadcast(e,s),D++}catch(r){t.logger?.warn("forwarder: candidate evaluation failed",{docID:t.entry.docID,version:t.entry.version,candidateGroupID:e,sourceGroupID:t.sourceGroupID,error:String(r)})}return{forwarded:D,skipped:I}}
1
+ import{catalogMatchesDoc as r,resolveAccessRule as e}from"@kubun/engine";import{computeBroadcastScopes as o}from"./scope-resolver.js";export function rethrowIfProgrammerError(r){if(r instanceof SyntaxError||r instanceof TypeError||r instanceof ReferenceError||r instanceof RangeError)throw r}export async function evaluateAndForward(t){let a,l;if(!1===t.forwarding)return{forwarded:0,skipped:0};let i=!0===t.forwarding?()=>!0:t.forwarding,n=(await t.p2pStore.getGroupsForMember(t.selfDID)).map(r=>r.id).filter(r=>r!==t.sourceGroupID);if(0===n.length)return{forwarded:0,skipped:0};try{a=await e(t.postState,t.postState.model,t.postState.owner,"read",{getUserModelAccessDefault:(r,e,o)=>t.graphStore.getUserModelAccessDefault(r,e,o),isMemberOfAnyCircle:(r,e)=>t.p2pStore.isMemberOfAnyCircle(r,e),isMemberOfAnyGroup:(r,e)=>t.p2pStore.isMemberOfAnyGroup(r,e),getModelInterfaces:r=>t.graphStore.getModelInterfaces(r)},t.defaultAccessLevel)}catch(r){return rethrowIfProgrammerError(r),t.logger?.warn("forwarder: resolveAccessRule failed, skipping forward",{docID:t.entry.docID,version:t.entry.version,sourceGroupID:t.sourceGroupID,error:String(r)}),{forwarded:0,skipped:0}}let s=new Set(a.allowedGroups??[]),f=new Set;if(null!=a.allowedCircles&&a.allowedCircles.length>0)try{for(let r of(await t.p2pStore.getCircles(a.allowedCircles)).values())null!=r.group_id&&f.add(r.group_id)}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCircles failed, skipping allowedCircles",{count:a.allowedCircles.length,error:String(r)})}let d=new Set;if(null!=a.allowedDIDs&&a.allowedDIDs.length>0)try{for(let r of(await t.p2pStore.getGroupsForMembers(a.allowedDIDs)).values())for(let e of r)d.add(e.id)}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getGroupsForMembers failed, skipping allowedDIDs",{count:a.allowedDIDs.length,error:String(r)})}let p=o({resolvedRule:{level:a.level,allowedGroupIDs:s,allowedCircleParentGroupIDs:f,allowedDIDGroupIDs:d},senderGroupIDs:n});if(0===p.length)return{forwarded:0,skipped:0};try{l=await t.p2pStore.getCirclesForMember(t.selfDID)}catch(r){return rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCirclesForMember failed",{selfDID:t.selfDID,error:String(r)}),{forwarded:0,skipped:0}}let c=new Set(p),g=new Map;for(let r of l){if(!c.has(r.group_id))continue;let e=g.get(r.group_id);null!=e?e.push(r):g.set(r.group_id,[r])}let u=new Set;for(let r of g.values())for(let e of r)for(let r of e.catalog_ids??[])u.add(r);let w=new Map;if(u.size>0)try{w=await t.graphStore.getCatalogs(Array.from(u))}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCatalogs failed, treating all as missing",{count:u.size,error:String(r)})}let D=0,I=0;for(let e of p)try{let o=g.get(e)??[],a=!1;for(let e of o){for(let o of e.catalog_ids??[]){let e=w.get(o);if(null!=e&&await r(e.filter_criteria,t.postState,t.p2pStore)){a=!0;break}}if(a)break}if(!a){I++;continue}let l={docID:t.entry.docID,modelID:t.entry.modelID,ownerDID:t.postState.owner,sourceGroupID:t.sourceGroupID,candidateGroupID:e,postState:t.postState};if(!await i(l)){I++;continue}if(!await t.p2pStore.recordBroadcast({docID:t.entry.docID,version:t.entry.version,mlsGroupID:e}))continue;let n={type:"mutation:apply",entries:[t.entry],senderPeerDID:t.selfDID};await t.scheduleBroadcast(e,n),D++}catch(r){t.logger?.warn("forwarder: candidate evaluation failed",{docID:t.entry.docID,version:t.entry.version,candidateGroupID:e,sourceGroupID:t.sourceGroupID,error:String(r)})}return{forwarded:D,skipped:I}}