@kubun/plugin-p2p 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/context/delegation.d.ts +4 -0
- package/lib/context/delegation.js +1 -0
- package/lib/context/group.js +1 -1
- package/lib/context/hub.js +1 -1
- package/lib/context/join.js +1 -1
- package/lib/context/types.d.ts +25 -2
- package/lib/groups/broadcast-service.d.ts +137 -4
- package/lib/groups/broadcast-service.js +1 -1
- package/lib/groups/broadcast.d.ts +120 -0
- package/lib/groups/broadcast.js +1 -1
- package/lib/groups/events.d.ts +16 -5
- package/lib/groups/events.js +1 -1
- package/lib/groups/group-handle-registry.d.ts +8 -0
- package/lib/groups/group-handle-registry.js +1 -1
- package/lib/groups/group-health-monitor.d.ts +45 -0
- package/lib/groups/group-health-monitor.js +1 -0
- package/lib/groups/invite-payload.d.ts +16 -0
- package/lib/groups/join-utils.d.ts +68 -2
- package/lib/groups/join-utils.js +1 -1
- package/lib/groups/manager.d.ts +15 -4
- package/lib/groups/manager.js +1 -1
- package/lib/groups/rejoin-codec.d.ts +14 -0
- package/lib/groups/rejoin-codec.js +1 -0
- package/lib/groups/store-received-grant.d.ts +47 -0
- package/lib/groups/store-received-grant.js +1 -0
- package/lib/groups/store-received-revocation.d.ts +46 -0
- package/lib/groups/store-received-revocation.js +1 -0
- package/lib/groups/wire-frame.d.ts +34 -0
- package/lib/groups/wire-frame.js +1 -0
- package/lib/hub/circle-catchup-requester.d.ts +58 -0
- package/lib/hub/circle-catchup-requester.js +1 -0
- package/lib/hub/circle-catchup-responder.d.ts +50 -0
- package/lib/hub/circle-catchup-responder.js +1 -0
- package/lib/hub/epoch-stale-detector.d.ts +18 -0
- package/lib/hub/epoch-stale-detector.js +1 -0
- package/lib/hub/errors.d.ts +7 -5
- package/lib/hub/errors.js +1 -1
- package/lib/hub/forward-remote-broadcast.d.ts +3 -3
- package/lib/hub/forward-remote-broadcast.js +1 -1
- package/lib/hub/group-channel.d.ts +30 -0
- package/lib/hub/group-channel.js +1 -1
- package/lib/hub/hub-connection.js +1 -1
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +1 -1
- package/lib/hub/receive-handler.d.ts +20 -2
- package/lib/hub/receive-handler.js +1 -1
- package/lib/hub/rejoin-manager.d.ts +78 -0
- package/lib/hub/rejoin-manager.js +1 -0
- package/lib/hub/rejoin-responder.d.ts +32 -0
- package/lib/hub/rejoin-responder.js +1 -0
- package/lib/hub/relay-manager.d.ts +23 -1
- package/lib/hub/relay-manager.js +1 -1
- package/lib/hub/send-handler.d.ts +16 -0
- package/lib/hub/send-handler.js +1 -1
- package/lib/hub/wiring.d.ts +33 -3
- package/lib/hub/wiring.js +1 -1
- package/lib/index.d.ts +19 -5
- package/lib/index.js +1 -1
- package/lib/protocol.d.ts +7 -0
- package/lib/protocol.js +1 -1
- package/lib/schema.d.ts +2 -2
- package/lib/schema.js +79 -3
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +1 -0
- package/lib/sync/broadcast-sender.js +1 -1
- package/lib/sync/errors.d.ts +11 -0
- package/lib/sync/errors.js +1 -0
- package/lib/sync/forwarder.d.ts +1 -0
- package/lib/sync/forwarder.js +1 -1
- package/lib/sync/handlers.d.ts +3 -18
- package/lib/sync/handlers.js +1 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +2 -0
- package/lib/sync/hub-tunnel-sync-listener.js +1 -1
- package/lib/sync/merkle-apply.js +1 -1
- package/lib/sync/merkle-channel.d.ts +19 -0
- package/lib/sync/merkle-channel.js +1 -1
- package/lib/sync/peer-registry.d.ts +7 -0
- package/lib/sync/peer-registry.js +1 -1
- package/lib/sync/sync-client.d.ts +2 -0
- package/lib/sync/sync-client.js +1 -1
- package/lib/sync/sync-manager.js +1 -1
- package/lib/types.d.ts +122 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +1 -0
- package/package.json +41 -41
- package/lib/sync/catalog-match.d.ts +0 -13
- package/lib/sync/catalog-match.js +0 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { GroupHealthCondition, GroupHealthSnapshot, GroupHealthState } from '../types.js';
|
|
2
|
+
import type { P2PEventEmitter } from './events.js';
|
|
3
|
+
export type GroupHealthMonitorParams = {
|
|
4
|
+
emitter: P2PEventEmitter;
|
|
5
|
+
/** Tuning for the `epoch-stale` condition. Defaults to a threshold of 3. */
|
|
6
|
+
epochStale?: {
|
|
7
|
+
threshold?: number;
|
|
8
|
+
};
|
|
9
|
+
/** Clock for `lastTransitionAt` stamps. Defaults to `Date.now`. */
|
|
10
|
+
now?: () => number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Per-group health state machine. Counts/latches failure signals per
|
|
14
|
+
* `(groupID, condition)` and emits `groupHealthChanged` on transitions.
|
|
15
|
+
* Crypto- and persistence-free: callers classify failures before signalling.
|
|
16
|
+
*
|
|
17
|
+
* State is in-memory per-process — a restart re-derives it from the next signal.
|
|
18
|
+
* Transitions are synchronous (no `await`), so calls cannot interleave; signals
|
|
19
|
+
* arriving while a rejoin is in flight (`recovering`) are ignored — the recovery
|
|
20
|
+
* owns the group until it resolves.
|
|
21
|
+
*/
|
|
22
|
+
export declare class GroupHealthMonitor {
|
|
23
|
+
#private;
|
|
24
|
+
constructor(params: GroupHealthMonitorParams);
|
|
25
|
+
/**
|
|
26
|
+
* Record a failure observation for a condition. Below the condition's
|
|
27
|
+
* threshold this only increments the counter; on reaching it the group
|
|
28
|
+
* transitions to `degraded` (emitted once, then latched). Ignored while the
|
|
29
|
+
* group is `recovering` (the rejoin owns it) or already `degraded`/failed.
|
|
30
|
+
*/
|
|
31
|
+
signal(groupID: string, condition: GroupHealthCondition): void;
|
|
32
|
+
/**
|
|
33
|
+
* Clear a condition to healthy and re-arm (counter reset), driven by the
|
|
34
|
+
* success path. Silent: only recovery (via the mark* methods) is surfaced.
|
|
35
|
+
*/
|
|
36
|
+
reset(groupID: string, condition: GroupHealthCondition): void;
|
|
37
|
+
markRecovering(groupID: string): void;
|
|
38
|
+
/** Emit the transient `recovered` event, then settle the phase to `healthy`. */
|
|
39
|
+
markRecovered(groupID: string): void;
|
|
40
|
+
/** Leave the group `recovery-failed` until a later success resets it. */
|
|
41
|
+
markRecoveryFailed(groupID: string): void;
|
|
42
|
+
/** Aggregate state: the first non-healthy condition phase, else `healthy`. */
|
|
43
|
+
getState(groupID: string): GroupHealthState;
|
|
44
|
+
getHealth(groupID: string): GroupHealthSnapshot;
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class GroupHealthMonitor{#e;#t;#s;#h=new Map;#a=new Map;constructor(e){this.#e=e.emitter,this.#t=e.epochStale?.threshold??3,this.#s=e.now??Date.now}signal(e,t){let s=this.#r(e,t);"healthy"===s.phase&&(s.count+=1,s.count>=this.#i(t)&&(s.phase="degraded",this.#o(e),this.#l(e,t,"degraded")))}reset(e,t){let s=this.#r(e,t);s.count=0,"healthy"!==s.phase&&(s.phase="healthy",this.#o(e))}markRecovering(e){this.#r(e,"epoch-stale").phase="recovering",this.#o(e),this.#l(e,"epoch-stale","recovering")}markRecovered(e){let t=this.#r(e,"epoch-stale");t.count=0,t.phase="healthy",this.#o(e),this.#l(e,"epoch-stale","recovered")}markRecoveryFailed(e){this.#r(e,"epoch-stale").phase="recovery-failed",this.#o(e),this.#l(e,"epoch-stale","recovery-failed")}getState(e){let t=this.#h.get(e);if(null==t)return"healthy";for(let e of t.values())if("healthy"!==e.phase)return e.phase;return"healthy"}getHealth(e){return{state:this.getState(e),lastTransitionAt:this.#a.get(e)??null}}#o(e){this.#a.set(e,this.#s())}#i(e){return"epoch-stale"===e?this.#t:1/0}#r(e,t){let s=this.#h.get(e);null==s&&(s=new Map,this.#h.set(e,s));let h=s.get(t);return null==h&&(h={phase:"healthy",count:0},s.set(t,h)),h}#l(e,t,s){this.#e.emit("groupHealthChanged",{groupID:e,condition:t,state:s})}}
|
|
@@ -19,6 +19,22 @@ export type InvitePayload = {
|
|
|
19
19
|
invite: Invite;
|
|
20
20
|
welcomeMessage: unknown;
|
|
21
21
|
ratchetTree: unknown;
|
|
22
|
+
/**
|
|
23
|
+
* Optional initial write-grant capability tokens (stringified JWTs) the
|
|
24
|
+
* inviter wants the joiner to hold immediately, before it starts receiving
|
|
25
|
+
* group broadcasts. Travels alongside the MLS invite as plain app-level data
|
|
26
|
+
* and is applied after the join completes — tokens addressed to the joiner
|
|
27
|
+
* are stored, others ignored.
|
|
28
|
+
*/
|
|
29
|
+
grants?: Array<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Optional initial signed-revocation tokens the inviter wants the joiner to
|
|
32
|
+
* apply immediately, before it starts receiving group broadcasts. Pending
|
|
33
|
+
* revocations (cap not yet known by the inviter) do NOT travel — only
|
|
34
|
+
* verified ones — though the receiving joiner re-cross-checks them anyway
|
|
35
|
+
* against any caps it already holds.
|
|
36
|
+
*/
|
|
37
|
+
revocations?: Array<string>;
|
|
22
38
|
};
|
|
23
39
|
export declare function encodeJoinRequest(payload: JoinRequestPayload): string;
|
|
24
40
|
export declare function decodeJoinRequest(encoded: string): JoinRequestPayload;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { StoreProvider } from '@kubun/db';
|
|
2
|
+
import type { HLC } from '@kubun/hlc';
|
|
3
|
+
import type { Logger } from '@kubun/logger';
|
|
2
4
|
import type { GroupData } from '../types.js';
|
|
3
|
-
import type {
|
|
5
|
+
import type { P2PEventEmitter } from './events.js';
|
|
4
6
|
export type FinalizeJoinedGroupParams = {
|
|
5
7
|
stores: StoreProvider;
|
|
6
|
-
emitter:
|
|
8
|
+
emitter: P2PEventEmitter;
|
|
7
9
|
invite: {
|
|
8
10
|
groupID: string;
|
|
9
11
|
};
|
|
@@ -11,3 +13,67 @@ export type FinalizeJoinedGroupParams = {
|
|
|
11
13
|
export declare function finalizeJoinedGroup(params: FinalizeJoinedGroupParams): Promise<{
|
|
12
14
|
group: GroupData | null;
|
|
13
15
|
}>;
|
|
16
|
+
export type ApplyInviteGrantsParams = {
|
|
17
|
+
stores: StoreProvider;
|
|
18
|
+
/** DID of the joining device — only grants addressed to it are stored. */
|
|
19
|
+
selfDID: string;
|
|
20
|
+
/** Group the joined invite belongs to. */
|
|
21
|
+
groupID: string;
|
|
22
|
+
/** Stringified capability JWTs carried by the invite. */
|
|
23
|
+
grants?: Array<string>;
|
|
24
|
+
/** Joiner's HLC, used to stamp each stored held row. */
|
|
25
|
+
hlc: HLC;
|
|
26
|
+
/**
|
|
27
|
+
* Optional emitter notified after each held row is written. Lets the joiner's
|
|
28
|
+
* `ownDelegationTokenAdded` subscription surface invite-bootstrap grants
|
|
29
|
+
* without waiting for a subsequent broadcast.
|
|
30
|
+
*/
|
|
31
|
+
emitter?: P2PEventEmitter;
|
|
32
|
+
logger?: Logger;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Apply invite-bootstrap grants after a join completes. Each token is verified
|
|
36
|
+
* and, when addressed to the joining device, stored as a held row so the
|
|
37
|
+
* engine's auto-attach can use it on the very first mutation — before any group
|
|
38
|
+
* broadcast arrives.
|
|
39
|
+
*
|
|
40
|
+
* No grantor HLC travels in the invite (it carries plain tokens, not the
|
|
41
|
+
* grantor's issued rows), so the joiner stamps a fresh HLC of its own at apply
|
|
42
|
+
* time. This is consistent with how every other join-time row the joiner
|
|
43
|
+
* materializes is locally stamped.
|
|
44
|
+
*
|
|
45
|
+
* Returns the number of grants that resulted in a stored held row.
|
|
46
|
+
*/
|
|
47
|
+
export declare function applyInviteGrants(params: ApplyInviteGrantsParams): Promise<number>;
|
|
48
|
+
export type ApplyInviteRevocationsParams = {
|
|
49
|
+
stores: StoreProvider;
|
|
50
|
+
/** Group the joined invite belongs to. */
|
|
51
|
+
groupID: string;
|
|
52
|
+
/** Signed revocation JWTs carried by the invite. */
|
|
53
|
+
revocations?: Array<string>;
|
|
54
|
+
/** Joiner's HLC, used to stamp each stored revocation row. */
|
|
55
|
+
hlc: HLC;
|
|
56
|
+
/**
|
|
57
|
+
* DID of the joining device. Forwarded to `storeReceivedRevocation` for
|
|
58
|
+
* symmetry; revocation storage is identity-agnostic but the helper accepts
|
|
59
|
+
* the param.
|
|
60
|
+
*/
|
|
61
|
+
selfDID: string;
|
|
62
|
+
/** Optional emitter forwarded to `storeReceivedRevocation`. */
|
|
63
|
+
emitter?: P2PEventEmitter;
|
|
64
|
+
logger?: Logger;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Apply invite-bootstrap revocations after a join completes. Each token is
|
|
68
|
+
* verified and stored — cap-known locally with matching `iss` yields a
|
|
69
|
+
* verified row, cap-unknown yields a pending row that the joiner will
|
|
70
|
+
* cross-check when the cap later arrives.
|
|
71
|
+
*
|
|
72
|
+
* The inviter's caller is responsible for filtering to verified revocations
|
|
73
|
+
* only; pending revocations do not travel via the invite envelope. The joiner
|
|
74
|
+
* stamps a fresh HLC since the inviter's HLC for these rows is not in the
|
|
75
|
+
* payload.
|
|
76
|
+
*
|
|
77
|
+
* Returns the number of revocations that resulted in a stored row.
|
|
78
|
+
*/
|
|
79
|
+
export declare function applyInviteRevocations(params: ApplyInviteRevocationsParams): Promise<number>;
|
package/lib/groups/join-utils.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{HLC as e}from"@kubun/hlc";import{getP2PStore as t}from"@kubun/store-p2p";import{toISO as r}from"../context/types.js";import{storeReceivedGrant as n}from"./store-received-grant.js";import{storeReceivedRevocation as i}from"./store-received-revocation.js";export async function finalizeJoinedGroup(e){let{stores:n,emitter:i,invite:o}=e,a=await t(n),l=await a.getGroup(o.groupID),p=null!=l?{id:l.id,name:l.name,description:l.description,createdBy:l.created_by,createdAt:r(l.created_at)}:null;return null!=p&&await i.emit("groupJoined",p),{group:p}}export async function applyInviteGrants(r){let{stores:i,selfDID:o,groupID:a,grants:l,hlc:p,emitter:u,logger:c}=r;if(null==l||0===l.length)return 0;let s=await t(i);return(await Promise.all(l.map(t=>n({p2pStore:s,token:t,groupID:a,hlc:e.serialize(p.now()),selfDID:o,...null!=u?{emitter:u}:{},...null!=c?{logger:c}:{}})))).reduce((e,t)=>e+ +!!t,0)}export async function applyInviteRevocations(r){let{stores:n,groupID:o,revocations:a,hlc:l,selfDID:p,emitter:u,logger:c}=r;if(null==a||0===a.length)return 0;let s=await t(n);return(await Promise.all(a.map(t=>i({p2pStore:s,token:t,groupID:o,hlc:e.serialize(l.now()),selfDID:p,...null!=u?{emitter:u}:{},...null!=c?{logger:c}:{}})))).reduce((e,t)=>e+ +!!t,0)}
|
package/lib/groups/manager.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type GroupPermission, type Invite, type KeyPackageBundle } from '@enkaku/group';
|
|
2
2
|
import type { Identity, OwnIdentity } from '@enkaku/token';
|
|
3
3
|
import type { StoreProvider } from '@kubun/db';
|
|
4
|
+
import { HLC } from '@kubun/hlc';
|
|
4
5
|
import type { GroupBroadcastMessage } from './broadcast.js';
|
|
5
6
|
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
6
7
|
import type { SuggestedHub } from './invite-payload.js';
|
|
@@ -13,6 +14,14 @@ export type GroupManagerParams = {
|
|
|
13
14
|
* the registry's per-group mutex with write-through persist.
|
|
14
15
|
*/
|
|
15
16
|
registry: GroupHandleRegistry;
|
|
17
|
+
/**
|
|
18
|
+
* Device-wide monotonic clock for stamping group/circle/member metadata.
|
|
19
|
+
* Required: production passes the engine's single instance so every write
|
|
20
|
+
* from this device — graph mutations and group metadata alike — advances one
|
|
21
|
+
* shared clock, preventing two same-millisecond writes from minting identical
|
|
22
|
+
* timestamps (the second would silently lose under last-writer-wins).
|
|
23
|
+
*/
|
|
24
|
+
hlc: HLC;
|
|
16
25
|
};
|
|
17
26
|
export type CreateGroupParams = {
|
|
18
27
|
stores: StoreProvider;
|
|
@@ -46,8 +55,10 @@ export type InviteToGroupParams = {
|
|
|
46
55
|
};
|
|
47
56
|
export type InviteToGroupResult = {
|
|
48
57
|
invite: Invite;
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
/** Framed MLSMessage(Welcome) bytes for the invitee (`@enkaku/group@0.16.1`). */
|
|
59
|
+
welcomeMessage: Uint8Array;
|
|
60
|
+
/** Framed MLSMessage(Commit) bytes to fan out to existing members. */
|
|
61
|
+
commitMessage: Uint8Array;
|
|
51
62
|
/**
|
|
52
63
|
* Ratchet tree from the post-commit handle. Required by `joinGroup`
|
|
53
64
|
* callers that build an invite payload — they no longer hold a
|
|
@@ -76,7 +87,7 @@ export type RemoveMemberParams = {
|
|
|
76
87
|
memberDID: string;
|
|
77
88
|
};
|
|
78
89
|
export type RemoveMemberResult = {
|
|
79
|
-
commitMessage:
|
|
90
|
+
commitMessage: Uint8Array;
|
|
80
91
|
};
|
|
81
92
|
export type RemoveGroupMemberParams = {
|
|
82
93
|
stores: StoreProvider;
|
|
@@ -84,7 +95,7 @@ export type RemoveGroupMemberParams = {
|
|
|
84
95
|
memberDID: string;
|
|
85
96
|
};
|
|
86
97
|
export type RemoveGroupMemberResult = {
|
|
87
|
-
commitMessage:
|
|
98
|
+
commitMessage: Uint8Array;
|
|
88
99
|
};
|
|
89
100
|
export type LeaveGroupParams = {
|
|
90
101
|
stores: StoreProvider;
|
package/lib/groups/manager.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{commitInvite as e,createGroup as r,createInvite as t,processWelcome as i,removeMember as a}from"@enkaku/group";import{HLC as o}from"@kubun/hlc";import{getGraphStore as s}from"@kubun/store-graph";import{getP2PStore as
|
|
1
|
+
import{commitInvite as e,createGroup as r,createInvite as t,processWelcome as i,removeMember as a}from"@enkaku/group";import{HLC as o}from"@kubun/hlc";import{getGraphStore as s}from"@kubun/store-graph";import{getP2PStore as l}from"@kubun/store-p2p";import{bindHubToGroup as c,upsertHub as n}from"../hub/manager.js";export class GroupManager{#e;#r;#t;#i;constructor(e){this.#e=e.identity.id,this.#r=e.getRandomID,this.#t=e.hlc,this.#i=e.registry}async createGroup(e){let t=this.#r(),{group:i}=await r(e.identity,t),a=e.hubs??[];return await e.stores.withTransaction(async r=>{let s=await l(r);for(let l of(await s.createGroup({id:t,name:e.name,description:e.description??"",created_by:e.createdBy??e.identity.id,hlc:o.serialize(this.#t.now())}),await this.#i.seed({groupID:t,handle:i,stores:r}),await s.addGroupMember({group_id:t,member_did:e.identity.id,role:"admin",hlc:o.serialize(this.#t.now())}),a)){let e=await n({stores:r,url:l.url,serverDID:l.serverDID??null});await c({stores:r,hubID:e.id,groupID:t})}}),{groupID:t}}async updateGroup(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.updateGroup(e.groupID,{name:e.update.name,description:e.update.description,hlc:r}),{broadcast:{type:"group:update",groupID:e.groupID,update:{...e.update,hlc:r}}}}async inviteToGroup(r){let i=await this.#i.withHandleReplacing(r.groupID,async i=>{let{invite:a}=await t({group:i,identity:r.identity,recipientDID:r.recipientDID,permission:r.permission}),{commitMessage:o,welcomeMessage:s,newGroup:l}=await e(i,r.recipientKeyPackage);return{result:{invite:a,commitMessage:o,welcomeMessage:s,ratchetTree:l.state.ratchetTree},updated:l}},{stores:r.stores}),a=await l(r.stores);return await a.addGroupMember({group_id:r.groupID,member_did:r.recipientDID,role:"admin"===r.permission?"admin":"member",hlc:o.serialize(this.#t.now())}),i}async joinGroup(e){let{group:r}=await i({identity:e.identity,invite:e.invite,welcome:e.welcomeMessage,keyPackageBundle:e.keyPackageBundle,ratchetTree:e.ratchetTree}),t=e.hubs??[];await e.stores.withTransaction(async i=>{await this.#i.seed({groupID:e.groupID,handle:r,stores:i});let a=await l(i);for(let r of(null==await a.getGroup(e.groupID)&&await a.createGroup({id:e.groupID,name:e.groupName,description:"",created_by:e.identity.id,hlc:o.serialize(this.#t.now())}),await a.addGroupMember({group_id:e.groupID,member_did:e.identity.id,role:"member",hlc:o.serialize(this.#t.now())}),t)){let t=await n({stores:i,url:r.url,serverDID:r.serverDID??null});await c({stores:i,hubID:t.id,groupID:e.groupID})}})}async removeMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let{commitMessage:t,newGroup:i}=await a(r,e.leafIndex);return{result:{commitMessage:t},updated:i}},{stores:e.stores}),t=await l(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID,o.serialize(this.#t.now())),{commitMessage:r}}async removeGroupMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let t=r.findMemberLeafIndex(e.memberDID);if(null==t)throw Error(`Member ${e.memberDID} not found in MLS group`);let{commitMessage:i,newGroup:o}=await a(r,t);return{result:{commitMessage:i},updated:o}},{stores:e.stores}),t=await l(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID,o.serialize(this.#t.now())),{commitMessage:r}}async leaveGroup(e){let r=await l(e.stores);await r.deleteMLSState(e.groupID,this.#e),await r.removeGroupMember(e.groupID,e.identity.id,o.serialize(this.#t.now())),e.stores.onCommit(()=>this.#i.invalidate(e.groupID))}async loadGroup(e,r){let t=await l(e),i=await t.getMLSState(r,this.#e);if(null!=i)return{epoch:i.epoch,credential:i.credential}}async createCircle(e){let r=this.#r(),t=o.serialize(this.#t.now()),i=e.catalogIDs??[],a=await l(e.stores);return await a.createCircle({id:r,group_id:e.groupID,name:e.name,description:e.description??"",catalog_ids:i,hlc:t}),{circleID:r,broadcast:{type:"circle:create",circle:{id:r,groupID:e.groupID,name:e.name,description:e.description??"",catalogIDs:i,hlc:t}}}}async updateCircle(e){let r=o.serialize(this.#t.now()),t=[],i=await l(e.stores);if(null!=e.update.catalogIDs){let r=await i.getCircle(e.circleID),a=new Set(null!=r?r.catalog_ids:[]),o=e.update.catalogIDs.filter(e=>!a.has(e)),l=await s(e.stores);for(let e of(await Promise.all(o.map(e=>l.getCatalog(e)))))null!=e&&t.push({type:"catalog:create",catalog:{id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:e.filter_criteria,hlc:e.hlc}})}await i.updateCircle(e.circleID,{name:e.update.name,description:e.update.description,catalog_ids:e.update.catalogIDs,hlc:r});let a={type:"circle:update",circleID:e.circleID,update:{...e.update,hlc:r}};return{broadcast:a,broadcasts:[a,...t]}}async deleteCircle(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.markCircleRemoved(e.circleID,r),{broadcast:{type:"circle:delete",circleID:e.circleID,hlc:r}}}async addCircleMember(e){let r=await l(e.stores);if(!await r.isGroupMember(e.groupID,e.memberDID))throw Error(`${e.memberDID} is not a member of group ${e.groupID}`);let t=o.serialize(this.#t.now());return await r.addCircleMember({circle_id:e.circleID,member_did:e.memberDID,role:e.role,hlc:t}),{broadcast:{type:"member:add",member:{circleID:e.circleID,memberDID:e.memberDID,role:e.role,hlc:t}}}}async removeCircleMember(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.removeCircleMember(e.circleID,e.memberDID,r),{broadcast:{type:"member:remove",circleID:e.circleID,memberDID:e.memberDID,hlc:r}}}}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A stale device's request for a fresh GroupInfo. Cleartext on the wire. */
|
|
2
|
+
export type RejoinRequest = {
|
|
3
|
+
requestID: string;
|
|
4
|
+
};
|
|
5
|
+
/** A current member's reply carrying a framed `MLSMessage(GroupInfo)`. */
|
|
6
|
+
export type RejoinGroupInfo = {
|
|
7
|
+
requestID: string;
|
|
8
|
+
/** Framed `MLSMessage(GroupInfo)` bytes. */
|
|
9
|
+
groupInfo: Uint8Array;
|
|
10
|
+
};
|
|
11
|
+
export declare function encodeRejoinRequest(request: RejoinRequest): Uint8Array;
|
|
12
|
+
export declare function decodeRejoinRequest(bytes: Uint8Array): RejoinRequest;
|
|
13
|
+
export declare function encodeRejoinGroupInfo(message: RejoinGroupInfo): Uint8Array;
|
|
14
|
+
export declare function decodeRejoinGroupInfo(bytes: Uint8Array): RejoinGroupInfo;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{replacer as e,reviver as r}from"./mls-json.js";let o=new TextEncoder,n=new TextDecoder;export function encodeRejoinRequest(r){return o.encode(JSON.stringify(r,e))}export function decodeRejoinRequest(e){let o=JSON.parse(n.decode(e),r);if("string"!=typeof o?.requestID)throw Error("invalid rejoin-request: missing requestID");return{requestID:o.requestID}}export function encodeRejoinGroupInfo(r){return o.encode(JSON.stringify(r,e))}export function decodeRejoinGroupInfo(e){let o=JSON.parse(n.decode(e),r);if("string"!=typeof o?.requestID)throw Error("invalid rejoin-groupinfo: missing requestID");if(!(o.groupInfo instanceof Uint8Array))throw Error("invalid rejoin-groupinfo: missing groupInfo bytes");return{requestID:o.requestID,groupInfo:o.groupInfo}}
|
|
@@ -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,34 @@
|
|
|
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
|
+
readonly rejoinRequest: 2;
|
|
21
|
+
readonly rejoinGroupInfo: 3;
|
|
22
|
+
};
|
|
23
|
+
export type WireKind = (typeof WireKind)[keyof typeof WireKind];
|
|
24
|
+
/** Prepend the `kind` byte to a payload. */
|
|
25
|
+
export declare function frameWire(kind: WireKind, payload: Uint8Array): Uint8Array;
|
|
26
|
+
/**
|
|
27
|
+
* Split a framed payload into its `kind` and the remaining payload bytes.
|
|
28
|
+
* Throws on an empty frame or an unrecognized `kind` byte. The returned
|
|
29
|
+
* payload is a view (`subarray`) over the input, not a copy.
|
|
30
|
+
*/
|
|
31
|
+
export declare function unframeWire(framed: Uint8Array): {
|
|
32
|
+
kind: WireKind;
|
|
33
|
+
payload: Uint8Array;
|
|
34
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const WireKind={app:0,mls:1,rejoinRequest:2,rejoinGroupInfo:3};let r=new Set(Object.values(WireKind));export function frameWire(r,e){let n=new Uint8Array(e.length+1);return n[0]=r,n.set(e,1),n}export function unframeWire(e){if(0===e.length)throw Error("empty wire frame");let n=e[0];if(!r.has(n))throw Error(`unknown wire frame kind: ${n}`);return{kind:n,payload:e.subarray(1)}}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { GroupBroadcastMessage } from '../groups/broadcast.js';
|
|
3
|
+
/** The reply payload a current member sends back, correlated by `requestID`. */
|
|
4
|
+
type CatchupReply = Extract<GroupBroadcastMessage, {
|
|
5
|
+
type: 'circle-catchup:reply';
|
|
6
|
+
}>;
|
|
7
|
+
export type CircleCatchupRequesterParams = {
|
|
8
|
+
groupID: string;
|
|
9
|
+
/** Fresh correlation id per request (incl. retries). */
|
|
10
|
+
genRequestID: () => string;
|
|
11
|
+
/** Broadcast a `circle-catchup:request` on the group channel. */
|
|
12
|
+
sendRequest: (requestID: string) => Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Apply a received reply batch row-by-row (idempotent, order-independent).
|
|
15
|
+
* Mirrors `applyCatchupReply` bound to the local store/engine context.
|
|
16
|
+
*/
|
|
17
|
+
applyReply: (reply: CatchupReply) => Promise<void>;
|
|
18
|
+
requestTimeoutMs?: number;
|
|
19
|
+
maxAttempts?: number;
|
|
20
|
+
backoffMs?: (attempt: number) => number;
|
|
21
|
+
logger?: Logger;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Drives a rejoining device's circle/member catch-up for one group: broadcast a
|
|
25
|
+
* `circle-catchup:request`, await the first matching `circle-catchup:reply`
|
|
26
|
+
* (correlated by `requestID`) within a timeout, apply it, done. On timeout,
|
|
27
|
+
* retry with a capped full-jitter backoff up to `maxAttempts`.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors the rejoin manager's request/await/retry shape, minus any MLS
|
|
30
|
+
* join/confirm — there is no Commit to broadcast and no head to confirm against,
|
|
31
|
+
* because `applyReply` is idempotent and order-independent: one good reply
|
|
32
|
+
* lands the snapshot, and a duplicate or late reply is a harmless no-op.
|
|
33
|
+
*
|
|
34
|
+
* Exhaustion is NON-FATAL: a device that never hears back simply keeps the
|
|
35
|
+
* state it has; it resolves without throwing.
|
|
36
|
+
*/
|
|
37
|
+
export declare class CircleCatchupRequester {
|
|
38
|
+
#private;
|
|
39
|
+
constructor(params: CircleCatchupRequesterParams);
|
|
40
|
+
get inFlight(): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Route a received `circle-catchup:reply`: resolve the matching in-flight
|
|
43
|
+
* waiter. Unknown `requestID`s (peers' requests, stale or duplicate replies
|
|
44
|
+
* for an already-resolved attempt) are ignored — harmless.
|
|
45
|
+
*/
|
|
46
|
+
onCatchupReply(requestID: string, reply: CatchupReply): void;
|
|
47
|
+
/**
|
|
48
|
+
* Run the catch-up flow. Idempotent: a concurrent call while one is in flight
|
|
49
|
+
* shares the existing run's promise rather than starting a second.
|
|
50
|
+
*/
|
|
51
|
+
run(): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Stop a running catch-up and release timers. Idempotent; the loop checks
|
|
54
|
+
* `#disposed` between attempts, pending waiters resolve `null`. On channel close.
|
|
55
|
+
*/
|
|
56
|
+
dispose(): void;
|
|
57
|
+
}
|
|
58
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{sleep as e,unref as t}from"../utils.js";export class CircleCatchupRequester{#e;#t;#s;#i;#r;#l;#a;#u;#n=new Map;#o=null;#h=!1;constructor(e){this.#e=e.groupID,this.#t=e.genRequestID,this.#s=e.sendRequest,this.#i=e.applyReply,this.#r=e.requestTimeoutMs??5e3,this.#l=e.maxAttempts??8,this.#a=e.backoffMs??s,this.#u=e.logger}get inFlight(){return null!=this.#o}onCatchupReply(e,t){let s=this.#n.get(e);null!=s&&(this.#n.delete(e),s.resolve(t))}run(){if(null!=this.#o)return this.#o;let e=this.#p().finally(()=>{this.#o=null});return this.#o=e,e}async #p(){for(let t=0;t<this.#l&&!this.#h;t++){if(t>0&&(await e(this.#a(t)),this.#h))return;let s=await this.#c(this.#r);if(null!=s)try{await this.#i(s);return}catch(e){this.#u?.warn("circle catch-up: applying reply failed",{groupID:this.#e,error:e})}}this.#h||this.#u?.warn("circle catch-up: no usable reply, giving up",{groupID:this.#e,attempts:this.#l})}async #c(e){let t=this.#t(),s=this.#g(t,e);try{await this.#s(t)}catch(e){return this.#m(t),this.#u?.warn("circle catch-up: request send failed",{groupID:this.#e,error:e}),null}return s}#g(e,s){return new Promise(i=>{let r=setTimeout(()=>{this.#n.delete(e),i(null)},s);t(r),this.#n.set(e,{resolve:e=>{clearTimeout(r),i(e)},cancel:()=>{clearTimeout(r),i(null)}})})}#m(e){let t=this.#n.get(e);null!=t&&(this.#n.delete(e),t.cancel())}dispose(){for(let e of(this.#h=!0,this.#n.values()))e.cancel();this.#n.clear()}}function s(e){return Math.floor(Math.random()*Math.min(500*2**(e-1),5e3))}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { Circle, CircleMember } from '@kubun/store-p2p';
|
|
3
|
+
import type { CircleMemberSnapshot, CircleSnapshot } from '../groups/broadcast.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reads the group's circles + circle members from the store. Mirrors the
|
|
6
|
+
* `listCirclesByGroup` / `listCircleMembers` store API, both returning
|
|
7
|
+
* tombstoned rows when `includeRemoved` is set so the snapshot can carry
|
|
8
|
+
* removed entries.
|
|
9
|
+
*/
|
|
10
|
+
export type CircleCatchupStoreReader = {
|
|
11
|
+
listCirclesByGroup: (groupID: string, options?: {
|
|
12
|
+
includeRemoved?: boolean;
|
|
13
|
+
}) => Promise<Array<Circle>>;
|
|
14
|
+
listCircleMembers: (circleID: string, options?: {
|
|
15
|
+
includeRemoved?: boolean;
|
|
16
|
+
}) => Promise<Array<CircleMember>>;
|
|
17
|
+
};
|
|
18
|
+
export type CircleCatchupResponderParams = {
|
|
19
|
+
groupID: string;
|
|
20
|
+
/** Reads the group's circle + circle-member rows (including tombstoned). */
|
|
21
|
+
store: CircleCatchupStoreReader;
|
|
22
|
+
/** Broadcast a `circle-catchup:reply` to the group channel. */
|
|
23
|
+
sendCatchupReply: (requestID: string, circles: Array<CircleSnapshot>, circleMembers: Array<CircleMemberSnapshot>) => Promise<void>;
|
|
24
|
+
/** Jitter delay (ms) before replying. Defaults to a random 0..250ms. */
|
|
25
|
+
nextJitterMs?: () => number;
|
|
26
|
+
/** How long a `requestID` stays suppressed after being answered. */
|
|
27
|
+
suppressTTLMs?: number;
|
|
28
|
+
logger?: Logger;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Answers `circle-catchup:request`s with one batched snapshot of the group's
|
|
32
|
+
* circle + circle-member rows (including tombstoned, each carrying its authored
|
|
33
|
+
* HLC). Every current member could reply, so jitter (random pre-reply delay) +
|
|
34
|
+
* suppression (cancel on seeing a peer's reply for the same `requestID`)
|
|
35
|
+
* collapse the storm to ~1 reply. Keyed on `requestID` (fresh per attempt);
|
|
36
|
+
* suppression entries TTL out to bound memory.
|
|
37
|
+
*
|
|
38
|
+
* Mirrors the rejoin responder's storm-collapse shape; suppression collapses
|
|
39
|
+
* only duplicates of one `requestID`, so distinct `requestID`s still draw one
|
|
40
|
+
* reply each.
|
|
41
|
+
*/
|
|
42
|
+
export declare class CircleCatchupResponder {
|
|
43
|
+
#private;
|
|
44
|
+
constructor(params: CircleCatchupResponderParams);
|
|
45
|
+
/** Handle an inbound `circle-catchup:request`: schedule a jittered, suppressible reply. */
|
|
46
|
+
onCatchupRequest(requestID: string): void;
|
|
47
|
+
/** Handle an observed `circle-catchup:reply` (own or peer's): suppress this request. */
|
|
48
|
+
onCatchupReplySeen(requestID: string): void;
|
|
49
|
+
dispose(): void;
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{unref as e}from"../utils.js";function s(e){return{id:e.id,groupID:e.group_id,name:e.name,description:e.description,catalogIDs:e.catalog_ids,hlc:e.hlc,removedAtHLC:e.removed_at_hlc??null}}export class CircleCatchupResponder{#e;#s;#t;#r;#i;#p;#o=new Map;#l=new Map;constructor(e){this.#e=e.groupID,this.#s=e.store,this.#t=e.sendCatchupReply,this.#r=e.nextJitterMs??(()=>Math.floor(250*Math.random())),this.#i=e.suppressTTLMs??5e3,this.#p=e.logger}onCatchupRequest(s){if(this.#l.has(s)||this.#o.has(s))return;let t=setTimeout(()=>{this.#o.delete(s),this.#h(s)},this.#r());e(t),this.#o.set(s,t)}onCatchupReplySeen(e){let s=this.#o.get(e);null!=s&&(clearTimeout(s),this.#o.delete(e)),this.#n(e)}dispose(){for(let e of this.#o.values())clearTimeout(e);for(let e of(this.#o.clear(),this.#l.values()))clearTimeout(e);this.#l.clear()}async #h(e){if(!this.#l.has(e)){this.#n(e);try{let{circles:s,circleMembers:t}=await this.#u();await this.#t(e,s,t)}catch(s){this.#p?.warn("circle catch-up responder failed to reply",{groupID:this.#e,requestID:e,error:s})}}}async #u(){let e=await this.#s.listCirclesByGroup(this.#e,{includeRemoved:!0}),t=e.map(s),r=[];for(let s of e)for(let e of(await this.#s.listCircleMembers(s.id,{includeRemoved:!0})))r.push({circleID:e.circle_id,memberDID:e.member_did,role:e.role,hlc:e.hlc,removedAtHLC:e.removed_at_hlc??null});return{circles:t,circleMembers:r}}#n(s){let t=this.#l.get(s);null!=t&&clearTimeout(t);let r=setTimeout(()=>{this.#l.delete(s)},this.#i);e(r),this.#l.set(s,r)}}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
|
|
2
|
+
import type { ReceiveOutcome } from './receive-handler.js';
|
|
3
|
+
export type EpochStaleDetectorParams = {
|
|
4
|
+
groupID: string;
|
|
5
|
+
monitor: GroupHealthMonitor;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Maps a group's receive outcomes to `epoch-stale` signals (one per group):
|
|
9
|
+
* `applied` → reset; `decrypt-failed` → signal; `commit-out-of-order` → signal
|
|
10
|
+
* only on a REPEAT `sequenceID` (first sighting is normal replay; a repeat means
|
|
11
|
+
* the gap-filling Commit was GC'd at the hub and never arrives); else no signal.
|
|
12
|
+
* The seen-set grows only while stuck and clears on the next `applied`.
|
|
13
|
+
*/
|
|
14
|
+
export declare class EpochStaleDetector {
|
|
15
|
+
#private;
|
|
16
|
+
constructor(params: EpochStaleDetectorParams);
|
|
17
|
+
onReceiveOutcome(outcome: ReceiveOutcome, sequenceID: string): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class EpochStaleDetector{#e;#t;#r=new Set;constructor(e){this.#e=e.groupID,this.#t=e.monitor}onReceiveOutcome(e,t){switch(e){case"applied":this.#r.clear(),this.#t.reset(this.#e,"epoch-stale");return;case"decrypt-failed":this.#t.signal(this.#e,"epoch-stale");return;case"commit-out-of-order":this.#r.has(t)?this.#t.signal(this.#e,"epoch-stale"):this.#r.add(t);return;default:return}}}
|
package/lib/hub/errors.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export declare class ReconnectingError extends Error {
|
|
2
|
-
|
|
3
|
-
readonly attempt: number;
|
|
2
|
+
#private;
|
|
4
3
|
constructor(hubURL: string, attempt: number);
|
|
4
|
+
get hubURL(): string;
|
|
5
|
+
get attempt(): number;
|
|
5
6
|
}
|
|
6
7
|
/**
|
|
7
8
|
* Thrown by `DIDObservingTransport.read()` when the first observed `payload.iss`
|
|
@@ -17,12 +18,13 @@ export declare class ReconnectingError extends Error {
|
|
|
17
18
|
* message is delivered, when a row was already pinned at connect time.
|
|
18
19
|
*/
|
|
19
20
|
export declare class HubServerDIDMismatchError extends Error {
|
|
20
|
-
|
|
21
|
-
readonly expectedServerDID: string;
|
|
22
|
-
readonly observedServerDID: string;
|
|
21
|
+
#private;
|
|
23
22
|
constructor(params: {
|
|
24
23
|
hubURL: string;
|
|
25
24
|
expectedServerDID: string;
|
|
26
25
|
observedServerDID: string;
|
|
27
26
|
});
|
|
27
|
+
get hubURL(): string;
|
|
28
|
+
get expectedServerDID(): string;
|
|
29
|
+
get observedServerDID(): string;
|
|
28
30
|
}
|
package/lib/hub/errors.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export class ReconnectingError extends Error{
|
|
1
|
+
export class ReconnectingError extends Error{#e;#r;constructor(e,r){super(`broadcast wait timed out: hub ${e} reconnecting (attempt ${r})`),this.name="ReconnectingError",this.#e=e,this.#r=r}get hubURL(){return this.#e}get attempt(){return this.#r}}export class HubServerDIDMismatchError extends Error{#e;#t;#s;constructor(e){super(`hub at ${e.hubURL} signed responses with ${e.observedServerDID}; pinned to ${e.expectedServerDID}`),this.name="HubServerDIDMismatchError",this.#e=e.hubURL,this.#t=e.expectedServerDID,this.#s=e.observedServerDID}get hubURL(){return this.#e}get expectedServerDID(){return this.#t}get observedServerDID(){return this.#s}}
|
|
@@ -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 {
|
|
3
|
+
import type { P2PEventEmitter } from '../groups/events.js';
|
|
4
4
|
/**
|
|
5
5
|
* Map a `BroadcastEvent` from `BroadcastService` into the matching
|
|
6
|
-
* `
|
|
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:
|
|
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})}}}
|