@kubun/plugin-p2p 0.10.1 → 0.11.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 (145) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +3 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +105 -91
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +5 -1
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +61 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +11 -0
  28. package/lib/groups/group-health-monitor.js +139 -1
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +20 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +0 -6
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.js +198 -1
  73. package/lib/hub/wiring.d.ts +17 -48
  74. package/lib/hub/wiring.js +132 -1
  75. package/lib/index.d.ts +17 -16
  76. package/lib/index.js +392 -1
  77. package/lib/protocol.d.ts +1 -1
  78. package/lib/protocol.js +251 -1
  79. package/lib/schema.d.ts +2 -1
  80. package/lib/schema.js +439 -2
  81. package/lib/sync/authorize.d.ts +1 -1
  82. package/lib/sync/authorize.js +73 -1
  83. package/lib/sync/broadcast-queue.js +183 -1
  84. package/lib/sync/broadcast-sender.js +179 -1
  85. package/lib/sync/catalog-scope.js +68 -1
  86. package/lib/sync/errors.d.ts +3 -2
  87. package/lib/sync/errors.js +19 -1
  88. package/lib/sync/forwarder.js +277 -1
  89. package/lib/sync/handlers.js +160 -1
  90. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
  91. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  92. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  93. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  94. package/lib/sync/merkle-apply.d.ts +5 -0
  95. package/lib/sync/merkle-apply.js +194 -1
  96. package/lib/sync/merkle-channel.d.ts +2 -2
  97. package/lib/sync/merkle-channel.js +182 -1
  98. package/lib/sync/merkle-tree.js +120 -1
  99. package/lib/sync/peer-registry.d.ts +1 -1
  100. package/lib/sync/peer-registry.js +107 -1
  101. package/lib/sync/receive-access-gate.js +24 -1
  102. package/lib/sync/scope-resolver.js +41 -1
  103. package/lib/sync/sync-client.d.ts +3 -3
  104. package/lib/sync/sync-client.js +192 -1
  105. package/lib/sync/sync-manager.d.ts +2 -2
  106. package/lib/sync/sync-manager.js +272 -1
  107. package/lib/types.d.ts +11 -0
  108. package/lib/types.js +2 -1
  109. package/lib/util/mutex.js +10 -1
  110. package/lib/utils.js +21 -1
  111. package/package.json +47 -40
  112. package/lib/groups/broadcast-service.d.ts +0 -263
  113. package/lib/groups/broadcast-service.js +0 -1
  114. package/lib/groups/rejoin-codec.d.ts +0 -14
  115. package/lib/groups/rejoin-codec.js +0 -1
  116. package/lib/groups/wire-frame.d.ts +0 -34
  117. package/lib/groups/wire-frame.js +0 -1
  118. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  119. package/lib/hub/circle-catchup-requester.js +0 -1
  120. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  121. package/lib/hub/circle-catchup-responder.js +0 -1
  122. package/lib/hub/connection-pool.d.ts +0 -43
  123. package/lib/hub/connection-pool.js +0 -1
  124. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  125. package/lib/hub/epoch-stale-detector.js +0 -1
  126. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  127. package/lib/hub/forward-remote-broadcast.js +0 -1
  128. package/lib/hub/group-channel.d.ts +0 -89
  129. package/lib/hub/group-channel.js +0 -1
  130. package/lib/hub/hub-connection.d.ts +0 -96
  131. package/lib/hub/hub-connection.js +0 -1
  132. package/lib/hub/receive-handler.d.ts +0 -51
  133. package/lib/hub/receive-handler.js +0 -1
  134. package/lib/hub/rejoin-manager.d.ts +0 -78
  135. package/lib/hub/rejoin-manager.js +0 -1
  136. package/lib/hub/rejoin-responder.d.ts +0 -32
  137. package/lib/hub/rejoin-responder.js +0 -1
  138. package/lib/hub/relay-manager.d.ts +0 -142
  139. package/lib/hub/relay-manager.js +0 -1
  140. package/lib/hub/send-handler.d.ts +0 -40
  141. package/lib/hub/send-handler.js +0 -1
  142. package/lib/hub/tunnel-inbox.d.ts +0 -20
  143. package/lib/hub/tunnel-inbox.js +0 -1
  144. package/lib/hub/wait-for-gate.d.ts +0 -14
  145. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,116 @@
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}
1
+ import { verifyToken } from '@kokuin/token';
2
+ /**
3
+ * Verify a received capability grant and, when it is addressed to this device,
4
+ * store it as a held row so the engine's auto-attach can reuse it on later
5
+ * mutations.
6
+ *
7
+ * Returns `true` when the held row was inserted or materially changed (LWW
8
+ * upsert with a newer hlc and at least one differing content field). Returns
9
+ * `false` when no row change occurred — verification failure, audience
10
+ * mismatch, missing required claims, LWW lost against an existing row, or an
11
+ * idempotent re-broadcast with identical content. Never throws on a forged or
12
+ * malformed token — it is skipped like an unverifiable broadcast entry.
13
+ *
14
+ * Shared by the `delegation:share` broadcast path and the invite-bootstrap
15
+ * path so both apply identical verify + audience + null-guard + coercion logic.
16
+ */ export async function storeReceivedGrant(params) {
17
+ const { delegationStore, p2pStore, token, groupID, hlc, selfDID, emitter, logger } = params;
18
+ // Verify the token's signature before trusting any of its claims. A forged
19
+ // or tampered token throws here; skip it — no row stored, no throw.
20
+ let payload;
21
+ try {
22
+ const verified = await verifyToken(token);
23
+ payload = verified.payload;
24
+ } catch (error) {
25
+ logger?.warn('received grant token verification failed, skipping', {
26
+ groupID,
27
+ error
28
+ });
29
+ return false;
30
+ }
31
+ // Only store grants addressed to this device.
32
+ if (payload.aud !== selfDID) {
33
+ return false;
34
+ }
35
+ if (payload.jti == null || payload.exp == null) {
36
+ logger?.warn('received grant token missing jti/exp, skipping', {
37
+ groupID
38
+ });
39
+ return false;
40
+ }
41
+ const resource = typeof payload.res === 'string' ? payload.res : JSON.stringify(payload.res);
42
+ const act = typeof payload.act === 'string' ? payload.act : JSON.stringify(payload.act);
43
+ const changed = await delegationStore.addDelegationToken({
44
+ jti: payload.jti,
45
+ grantor: payload.sub,
46
+ audience: payload.aud,
47
+ token,
48
+ resource,
49
+ act,
50
+ exp: payload.exp,
51
+ hlc
52
+ });
53
+ // Record the group this grant belongs to on the p2p edge so the p2p layer can
54
+ // resolve the cap's group for revoke-broadcast targeting and listing
55
+ // enrichment. Keyed by `jti`; idempotent on re-broadcast.
56
+ await p2pStore.addGroupDelegation({
57
+ group_id: groupID,
58
+ jti: payload.jti
59
+ });
60
+ // Pending revocations are blind cross-checks: when the matching cap finally
61
+ // arrives we either promote the revocation to verified (issuer matches) or
62
+ // drop it as a forged broadcast (issuer mismatch). Settled before any
63
+ // `delegationTokenAdded` emission so subscribers see consistent ground
64
+ // truth from the revocation backend on the same tick.
65
+ if (changed) {
66
+ const pending = await delegationStore.getPendingRevocationByJTI(payload.jti);
67
+ if (pending != null) {
68
+ if (pending.revoker_did === payload.sub) {
69
+ // Pin the `verified_at` we send to subscribers to the same value the
70
+ // store writes; passing it explicitly avoids a millisecond-scale
71
+ // skew between the row and the emitted event under clock-tick
72
+ // boundaries.
73
+ const verifiedAt = Math.floor(Date.now() / 1000);
74
+ const flipped = await delegationStore.markRevocationVerified(payload.jti, {
75
+ cap_exp: payload.exp,
76
+ verified_at: verifiedAt
77
+ });
78
+ if (flipped && emitter != null) {
79
+ await emitter.emit('delegationTokenRevoked', {
80
+ jti: payload.jti,
81
+ grantor: payload.sub,
82
+ audience: payload.aud,
83
+ revokerDID: pending.revoker_did,
84
+ revokedAt: pending.revoked_iat,
85
+ verifiedAt,
86
+ capExp: payload.exp,
87
+ groupID
88
+ });
89
+ }
90
+ } else {
91
+ logger?.warn('pending revocation iss does not match arriving cap, dropping', {
92
+ jti: payload.jti,
93
+ pendingIss: pending.revoker_did,
94
+ capGrantor: payload.sub,
95
+ groupID
96
+ });
97
+ await delegationStore.deletePendingRevocation(payload.jti);
98
+ }
99
+ }
100
+ }
101
+ // Emit only when the held row was actually inserted or materially changed.
102
+ // Idempotent re-broadcasts (LWW lost, or won with byte-identical content)
103
+ // are silent — subscribers should not see duplicates for a token they
104
+ // already received.
105
+ if (changed && emitter != null) {
106
+ await emitter.emit('delegationTokenAdded', {
107
+ jwt: token,
108
+ jti: payload.jti,
109
+ grantor: payload.sub,
110
+ audience: payload.aud,
111
+ resource,
112
+ exp: payload.exp
113
+ });
114
+ }
115
+ return changed;
116
+ }
@@ -1,9 +1,9 @@
1
1
  import type { Logger } from '@kubun/logger';
2
- import type { P2PStoreAPI } from '@kubun/store-p2p';
2
+ import type { DelegationStoreAPI } from '@kubun/store-delegation';
3
3
  import type { P2PEventEmitter } from './events.js';
4
4
  export type StoreReceivedRevocationParams = {
5
- /** P2P store the revocation row is written to. */
6
- p2pStore: P2PStoreAPI;
5
+ /** Delegation store the revocation row is written to. */
6
+ delegationStore: DelegationStoreAPI;
7
7
  /** Stringified signed revocation JWT carrying `{ jti, iss, rev, iat }`. */
8
8
  token: string;
9
9
  /** Group the revocation belongs to. */
@@ -1 +1,82 @@
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}
1
+ import { verifyToken } from '@kokuin/token';
2
+ /**
3
+ * Verify a received revocation token and write its row to the P2P store.
4
+ *
5
+ * Verified-on-arrival: when the referenced capability is already known
6
+ * locally and the revocation's `iss` matches that capability's `grantor`,
7
+ * the row is stored with `verified_at` populated so the hot-path
8
+ * `isRevoked` gate fires immediately. When the capability is unknown
9
+ * locally, the row is stored as pending (`verified_at` null) until a later
10
+ * `delegation:share` triggers a cross-check that flips it via
11
+ * `markRevocationVerified`. A signature-verified token whose `iss` does
12
+ * not match a known capability's `grantor` is rejected outright — a forged
13
+ * revocation must not pollute the local store.
14
+ *
15
+ * Returns `true` when the row was inserted or materially changed (LWW
16
+ * upsert). Returns `false` on verification failure, non-revocation
17
+ * payload, iss/grantor mismatch, or a lost/idempotent LWW upsert. Never
18
+ * throws on a forged or malformed token — it is skipped like an
19
+ * unverifiable broadcast entry.
20
+ */ export async function storeReceivedRevocation(params) {
21
+ const { delegationStore, token, groupID, hlc, emitter, logger } = params;
22
+ let payload;
23
+ try {
24
+ const verified = await verifyToken(token);
25
+ payload = verified.payload;
26
+ } catch (error) {
27
+ logger?.debug('received revocation token verification failed, skipping', {
28
+ groupID,
29
+ error
30
+ });
31
+ return false;
32
+ }
33
+ if (payload.rev !== true) {
34
+ logger?.debug('received revocation token is not a revocation record, skipping', {
35
+ groupID
36
+ });
37
+ return false;
38
+ }
39
+ const { jti, iss, iat } = payload;
40
+ const cap = await delegationStore.getDelegationTokenByJTI(jti);
41
+ let verifiedAt = null;
42
+ let capExp = null;
43
+ if (cap != null) {
44
+ if (cap.grantor !== iss) {
45
+ logger?.warn('received revocation iss does not match known capability grantor, skipping', {
46
+ groupID,
47
+ jti,
48
+ iss,
49
+ grantor: cap.grantor
50
+ });
51
+ return false;
52
+ }
53
+ verifiedAt = Math.floor(Date.now() / 1000);
54
+ capExp = cap.exp;
55
+ }
56
+ const changed = await delegationStore.addRevocation({
57
+ jti,
58
+ revoker_did: iss,
59
+ revoked_iat: iat,
60
+ revocation_token: token,
61
+ verified_at: verifiedAt,
62
+ cap_exp: capExp,
63
+ hlc
64
+ });
65
+ // Only verified-on-arrival writes are binding to local subscribers — a
66
+ // pending row carries no enforcement weight until the deferred cross-check
67
+ // promotes it. The cross-check path (in `storeReceivedGrant`) emits there
68
+ // when it flips a row from pending to verified.
69
+ if (changed && verifiedAt != null && cap != null && emitter != null) {
70
+ await emitter.emit('delegationTokenRevoked', {
71
+ jti,
72
+ grantor: cap.grantor,
73
+ audience: cap.audience,
74
+ revokerDID: iss,
75
+ revokedAt: iat,
76
+ verifiedAt,
77
+ capExp,
78
+ groupID
79
+ });
80
+ }
81
+ return changed;
82
+ }
@@ -0,0 +1,22 @@
1
+ import type { Logger } from '@kubun/logger';
2
+ export type AckBatchParams = {
3
+ flushMs: number;
4
+ flushMax: number;
5
+ send: (ids: Array<string>) => Promise<void>;
6
+ logger?: Logger;
7
+ };
8
+ export declare class AckBatch {
9
+ #private;
10
+ constructor(params: AckBatchParams);
11
+ add(id: string): void;
12
+ /**
13
+ * Drain the pending id set and send it via the configured sender. Never
14
+ * rejects: send errors are logged and the ids are dropped silently (the hub
15
+ * will redeliver unacknowledged messages, so the next receive will produce a
16
+ * fresh ack).
17
+ */
18
+ flush(): Promise<void>;
19
+ dispose(): void;
20
+ }
21
+ export declare function computeBackoff(attempt: number, base: number, max: number, jitter: number): number;
22
+ export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
@@ -0,0 +1,88 @@
1
+ export class AckBatch {
2
+ #flushMs;
3
+ #flushMax;
4
+ #send;
5
+ #logger;
6
+ #ids = new Set();
7
+ #timer = null;
8
+ constructor(params){
9
+ this.#flushMs = params.flushMs;
10
+ this.#flushMax = params.flushMax;
11
+ this.#send = params.send;
12
+ this.#logger = params.logger;
13
+ }
14
+ add(id) {
15
+ const wasEmpty = this.#ids.size === 0;
16
+ this.#ids.add(id);
17
+ if (this.#ids.size >= this.#flushMax) {
18
+ if (this.#timer !== null) {
19
+ clearTimeout(this.#timer);
20
+ this.#timer = null;
21
+ }
22
+ void this.flush();
23
+ return;
24
+ }
25
+ if (wasEmpty && this.#timer === null) {
26
+ this.#timer = setTimeout(()=>{
27
+ this.#timer = null;
28
+ void this.flush();
29
+ }, this.#flushMs);
30
+ }
31
+ }
32
+ /**
33
+ * Drain the pending id set and send it via the configured sender. Never
34
+ * rejects: send errors are logged and the ids are dropped silently (the hub
35
+ * will redeliver unacknowledged messages, so the next receive will produce a
36
+ * fresh ack).
37
+ */ async flush() {
38
+ if (this.#timer !== null) {
39
+ clearTimeout(this.#timer);
40
+ this.#timer = null;
41
+ }
42
+ if (this.#ids.size === 0) {
43
+ return;
44
+ }
45
+ const snapshot = Array.from(this.#ids);
46
+ this.#ids.clear();
47
+ try {
48
+ await this.#send(snapshot);
49
+ } catch (err) {
50
+ this.#logger?.error('ack batch send failed', {
51
+ error: err,
52
+ count: snapshot.length
53
+ });
54
+ }
55
+ }
56
+ dispose() {
57
+ if (this.#timer !== null) {
58
+ clearTimeout(this.#timer);
59
+ this.#timer = null;
60
+ }
61
+ this.#ids.clear();
62
+ }
63
+ }
64
+ // --- Backoff ---
65
+ export function computeBackoff(attempt, base, max, jitter) {
66
+ const exponential = base * 2 ** attempt;
67
+ const jittered = exponential * (1 + jitter * (2 * Math.random() - 1));
68
+ const clamped = Math.min(jittered, max);
69
+ return clamped < 0 ? 0 : clamped;
70
+ }
71
+ export function abortableSleep(ms, signal) {
72
+ return new Promise((resolve, reject)=>{
73
+ if (signal.aborted) {
74
+ reject(signal.reason ?? new Error('aborted'));
75
+ return;
76
+ }
77
+ const timer = setTimeout(()=>{
78
+ signal.removeEventListener('abort', onAbort);
79
+ resolve();
80
+ }, ms);
81
+ const onAbort = ()=>{
82
+ clearTimeout(timer);
83
+ signal.removeEventListener('abort', onAbort);
84
+ reject(signal.reason ?? new Error('aborted'));
85
+ };
86
+ signal.addEventListener('abort', onAbort);
87
+ });
88
+ }
@@ -1,13 +1,24 @@
1
- import { Disposer } from '@enkaku/async';
2
- import type { EventEmitter } from '@enkaku/event';
3
1
  import type { AnyClientMessageOf, AnyServerMessageOf, ClientTransportOf, ProtocolDefinition } from '@enkaku/protocol';
4
2
  import type { TransportEvents } from '@enkaku/transport';
3
+ import { Disposer } from '@sozai/async';
4
+ import type { EventEmitter } from '@sozai/event';
5
5
  export type ServerDIDObserver = (serverDID: string) => void | Promise<void>;
6
6
  export type ServerDIDMismatchObserver = (params: {
7
7
  hubURL: string;
8
8
  expectedServerDID: string;
9
9
  observedServerDID: string;
10
10
  }) => void;
11
+ /**
12
+ * Fired once per freshly-spawned hub client when its first signed response
13
+ * arrives. The hub URL is bound into the call site; consumers (hub manager
14
+ * `captureServerDID`) use the URL to look up the local hub row and apply the
15
+ * TOFU conflict matrix. Errors are caught and logged — capture never blocks
16
+ * message forwarding, even on `HubServerDIDConflictError`.
17
+ */
18
+ export type OnServerDIDObserved = (params: {
19
+ hubURL: string;
20
+ serverDID: string;
21
+ }) => void | Promise<void>;
11
22
  export type DIDObservingTransportParams<Protocol extends ProtocolDefinition> = {
12
23
  inner: ClientTransportOf<Protocol>;
13
24
  /**
@@ -1 +1,126 @@
1
- import{Disposer as e}from"@enkaku/async";import{HubServerDIDMismatchError as r}from"./errors.js";export class DIDObservingTransport extends e{#e;#r;#t;#n;#i;#s=!1;#l=null;constructor(e){super({signal:e.inner.signal,dispose:async r=>{await e.inner.dispose(r)}}),this.#e=e.inner,this.#r=e.onServerDID,this.#t=e.expectedServerDID,this.#n=e.hubURL,this.#i=e.onMismatch}get events(){return this.#e.events}getWritable(){return this.#e.getWritable()}async read(){if(null!=this.#l)throw this.#l;let e=await this.#e.read(),r=this.#a(e);if(null!=r)throw r;return e}async write(e){await this.#e.write(e)}[Symbol.asyncIterator](){return{next:async()=>{let e=await this.read();return e.done?{done:!0,value:e.value??null}:{done:!1,value:e.value}}}}#a(e){if(this.#s||e.done||null==e.value)return null;let t=e.value.payload,n=t?.iss;if("string"!=typeof n||0===n.length)return null;if(this.#s=!0,null!=this.#t){if(n===this.#t)return null;let e=new r({hubURL:this.#n??"",expectedServerDID:this.#t,observedServerDID:n});if(this.#l=e,null!=this.#i)try{this.#i({hubURL:this.#n??"",expectedServerDID:this.#t,observedServerDID:n})}catch{}return e}return Promise.resolve().then(()=>this.#r(n)).catch(()=>{}),null}}
1
+ import { Disposer } from '@sozai/async';
2
+ import { HubServerDIDMismatchError } from './errors.js';
3
+ /**
4
+ * Wraps a `ClientTransportOf<Protocol>` to enforce hub-server-DID
5
+ * TOFU + pinned-mismatch semantics on the receive side.
6
+ *
7
+ * - `expectedServerDID == null`: TOFU mode. First message bearing
8
+ * `payload.iss` fires `onServerDID(iss)` exactly once; subsequent messages
9
+ * never re-fire, even with a differing `iss`. Every message is forwarded
10
+ * to the inner Client untouched.
11
+ * - `expectedServerDID != null` and the first observed `iss` matches: the
12
+ * pin is already authoritative; `onServerDID` is NOT fired and forwarding
13
+ * continues normally.
14
+ * - `expectedServerDID != null` and the first observed `iss` differs: the
15
+ * wrapper enters a permanent failed state (`HubServerDIDMismatchError`).
16
+ * The current `read()` call AND every subsequent `read()` reject with the
17
+ * error; the offending message is NOT delivered. Inner-transport lifecycle
18
+ * stays the pool's responsibility — the wrapper does not dispose its inner.
19
+ *
20
+ * Extends `Disposer` and matches the `ClientTransportOf<Protocol>` shape
21
+ * structurally so it can be passed straight to `Client<Protocol>`.
22
+ */ export class DIDObservingTransport extends Disposer {
23
+ #inner;
24
+ #onServerDID;
25
+ #expectedServerDID;
26
+ #hubURL;
27
+ #onMismatch;
28
+ #fired = false;
29
+ #failure = null;
30
+ constructor(params){
31
+ super({
32
+ signal: params.inner.signal,
33
+ dispose: async (reason)=>{
34
+ await params.inner.dispose(reason);
35
+ }
36
+ });
37
+ this.#inner = params.inner;
38
+ this.#onServerDID = params.onServerDID;
39
+ this.#expectedServerDID = params.expectedServerDID;
40
+ this.#hubURL = params.hubURL;
41
+ this.#onMismatch = params.onMismatch;
42
+ }
43
+ get events() {
44
+ return this.#inner.events;
45
+ }
46
+ getWritable() {
47
+ return this.#inner.getWritable();
48
+ }
49
+ async read() {
50
+ if (this.#failure != null) {
51
+ throw this.#failure;
52
+ }
53
+ const result = await this.#inner.read();
54
+ const failure = this.#observe(result);
55
+ if (failure != null) {
56
+ throw failure;
57
+ }
58
+ return result;
59
+ }
60
+ async write(value) {
61
+ await this.#inner.write(value);
62
+ }
63
+ [Symbol.asyncIterator]() {
64
+ return {
65
+ next: async ()=>{
66
+ const result = await this.read();
67
+ if (result.done) {
68
+ return {
69
+ done: true,
70
+ value: result.value ?? null
71
+ };
72
+ }
73
+ return {
74
+ done: false,
75
+ value: result.value
76
+ };
77
+ }
78
+ };
79
+ }
80
+ /**
81
+ * Inspect a freshly-read result for `payload.iss`. Returns a
82
+ * `HubServerDIDMismatchError` to be thrown by the caller when in pinned
83
+ * mode and the observed DID does not match — in that case the wrapper
84
+ * also enters a permanent failed state and the offending message must NOT
85
+ * be returned to the caller. Returns `null` otherwise.
86
+ */ #observe(result) {
87
+ if (this.#fired || result.done || result.value == null) {
88
+ return null;
89
+ }
90
+ const payload = result.value.payload;
91
+ const iss = payload?.iss;
92
+ if (typeof iss !== 'string' || iss.length === 0) {
93
+ return null;
94
+ }
95
+ this.#fired = true;
96
+ if (this.#expectedServerDID != null) {
97
+ if (iss === this.#expectedServerDID) {
98
+ return null;
99
+ }
100
+ const error = new HubServerDIDMismatchError({
101
+ hubURL: this.#hubURL ?? '',
102
+ expectedServerDID: this.#expectedServerDID,
103
+ observedServerDID: iss
104
+ });
105
+ this.#failure = error;
106
+ if (this.#onMismatch != null) {
107
+ try {
108
+ this.#onMismatch({
109
+ hubURL: this.#hubURL ?? '',
110
+ expectedServerDID: this.#expectedServerDID,
111
+ observedServerDID: iss
112
+ });
113
+ } catch {
114
+ // observer errors must not mask the typed mismatch path
115
+ }
116
+ }
117
+ return error;
118
+ }
119
+ // TOFU mode — fire-and-forget: forwarding must not wait on the observer.
120
+ // Errors are swallowed at the wrapper; the pool-side handler logs them.
121
+ void Promise.resolve().then(()=>this.#onServerDID(iss)).catch(()=>{
122
+ // intentional: capture failures don't break the read loop in TOFU mode
123
+ });
124
+ return null;
125
+ }
126
+ }
@@ -1,9 +1,3 @@
1
- export declare class ReconnectingError extends Error {
2
- #private;
3
- constructor(hubURL: string, attempt: number);
4
- get hubURL(): string;
5
- get attempt(): number;
6
- }
7
1
  /**
8
2
  * Thrown by `DIDObservingTransport.read()` when the first observed `payload.iss`
9
3
  * does not match the pinned `expectedServerDID`. The transport enters a
package/lib/hub/errors.js CHANGED
@@ -1 +1,33 @@
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
+ /**
2
+ * Thrown by `DIDObservingTransport.read()` when the first observed `payload.iss`
3
+ * does not match the pinned `expectedServerDID`. The transport enters a
4
+ * permanent failed state: no message bearing the offending `iss` is delivered
5
+ * to the inner Client, and every subsequent `read()` rejects with the same
6
+ * error. In-flight Enkaku RPCs see this error as the `cause` of the Client's
7
+ * "Transport read failed" abort.
8
+ *
9
+ * Distinct from `HubServerDIDConflictError` (raised by manager-level
10
+ * `captureServerDID` when the pin row already differs from the observed DID
11
+ * on the TOFU capture path); this class is raised client-side, before any
12
+ * message is delivered, when a row was already pinned at connect time.
13
+ */ export class HubServerDIDMismatchError extends Error {
14
+ #hubURL;
15
+ #expectedServerDID;
16
+ #observedServerDID;
17
+ constructor(params){
18
+ super(`hub at ${params.hubURL} signed responses with ${params.observedServerDID}; pinned to ${params.expectedServerDID}`);
19
+ this.name = 'HubServerDIDMismatchError';
20
+ this.#hubURL = params.hubURL;
21
+ this.#expectedServerDID = params.expectedServerDID;
22
+ this.#observedServerDID = params.observedServerDID;
23
+ }
24
+ get hubURL() {
25
+ return this.#hubURL;
26
+ }
27
+ get expectedServerDID() {
28
+ return this.#expectedServerDID;
29
+ }
30
+ get observedServerDID() {
31
+ return this.#observedServerDID;
32
+ }
33
+ }
@@ -1,6 +1,31 @@
1
- import type { Identity } from '@enkaku/token';
2
- import { type ServerDIDObserver } from './did-observing-transport.js';
3
- import type { CreateHubClient } from './relay-manager.js';
1
+ import { Client } from '@enkaku/client';
2
+ import type { Identity } from '@kokuin/token';
3
+ import type { HubProtocol } from '@kumiai/hub-protocol';
4
+ import { type ServerDIDMismatchObserver, type ServerDIDObserver } from './did-observing-transport.js';
5
+ export type CreateHubClientOptions = {
6
+ /**
7
+ * Per-call observer fired once when the first signed response arrives. Set by
8
+ * the caller so observed server DIDs can be routed through TOFU capture with
9
+ * the hub URL bound. Factories that don't honour this opt simply skip TOFU
10
+ * capture for the spawned client.
11
+ */
12
+ onServerDID?: ServerDIDObserver;
13
+ /**
14
+ * Pinned `server_did` resolved from the local hub row at spawn time. When set,
15
+ * the underlying `DIDObservingTransport` enforces it: a non-matching first
16
+ * response raises `HubServerDIDMismatchError` and no message is delivered to
17
+ * the inner Client (hard-fail path). When unset, the wrapper falls back to
18
+ * TOFU capture via `onServerDID`.
19
+ */
20
+ expectedServerDID?: string;
21
+ /**
22
+ * Side-channel notifier fired when a pinned-DID mismatch is detected. Lets the
23
+ * caller log + evict on top of the typed error that propagates through the
24
+ * read loop.
25
+ */
26
+ onMismatch?: ServerDIDMismatchObserver;
27
+ };
28
+ export type CreateHubClient = (hubURL: string, opts?: CreateHubClientOptions) => Promise<Client<HubProtocol>>;
4
29
  export type CreateHTTPHubClientParams = {
5
30
  identity: Identity;
6
31
  fetch?: typeof globalThis.fetch;
@@ -1 +1,32 @@
1
- import{Client as e}from"@enkaku/client";import{ClientTransport as r}from"@enkaku/http-client-transport";import{DIDObservingTransport as t}from"./did-observing-transport.js";export function createHTTPHubClient(n){return async(o,i)=>{let l=new r({url:o,fetch:n.fetch}),u=i?.onServerDID??n.onServerDID,D=i?.expectedServerDID,c=i?.onMismatch;return new e({transport:null!=u||null!=D||null!=c?new t({inner:l,onServerDID:u??(()=>{}),expectedServerDID:D,hubURL:o,onMismatch:c}):l,identity:n.identity,serverID:n.serverID})}}
1
+ import { Client } from '@enkaku/client';
2
+ import { ClientTransport } from '@enkaku/http-fetch';
3
+ import { DIDObservingTransport } from './did-observing-transport.js';
4
+ export function createHTTPHubClient(params) {
5
+ return async (hubURL, opts)=>{
6
+ const inner = new ClientTransport({
7
+ url: hubURL,
8
+ fetch: params.fetch
9
+ });
10
+ const observer = opts?.onServerDID ?? params.onServerDID;
11
+ const expectedServerDID = opts?.expectedServerDID;
12
+ const onMismatch = opts?.onMismatch;
13
+ // Wrap whenever any DID-aware behaviour is needed: TOFU capture (observer),
14
+ // pinned-DID enforcement (expectedServerDID), or mismatch side-channel.
15
+ const needsWrapper = observer != null || expectedServerDID != null || onMismatch != null;
16
+ const transport = needsWrapper ? new DIDObservingTransport({
17
+ inner,
18
+ // Wrapper requires an observer; supply a no-op when only enforcement
19
+ // is requested. The TOFU branch in the wrapper is gated by
20
+ // `expectedServerDID == null` regardless.
21
+ onServerDID: observer ?? (()=>{}),
22
+ expectedServerDID,
23
+ hubURL,
24
+ onMismatch
25
+ }) : inner;
26
+ return new Client({
27
+ transport,
28
+ identity: params.identity,
29
+ serverID: params.serverID
30
+ });
31
+ };
32
+ }
@@ -0,0 +1,24 @@
1
+ import type { Client } from '@enkaku/client';
2
+ import type { Logger } from '@kubun/logger';
3
+ import type { HubProtocol } from '@kumiai/hub-protocol';
4
+ import type { HubLike } from '@kumiai/hub-tunnel';
5
+ export type CreateHubLikeParams = {
6
+ /**
7
+ * Builds a fresh `Client<HubProtocol>` bound to one hub URL + device identity.
8
+ * Called once on the first connect and again on every reconnect. Injected
9
+ * rather than constructed here so the adapter is testable against a fake
10
+ * client without standing up HTTP.
11
+ */
12
+ createClient: () => Client<HubProtocol> | Promise<Client<HubProtocol>>;
13
+ /** Authenticated DID of this device — drains receive and stamps publishes. */
14
+ localDID: string;
15
+ logger?: Logger;
16
+ ackFlushMs?: number;
17
+ ackFlushMax?: number;
18
+ backoffBaseMs?: number;
19
+ backoffMaxMs?: number;
20
+ backoffJitter?: number;
21
+ };
22
+ export declare function createHubLike(params: CreateHubLikeParams): HubLike & {
23
+ dispose: () => Promise<void>;
24
+ };