@kubun/plugin-p2p 0.11.0 → 0.12.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 (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +56 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -1,4 +1,5 @@
1
- import { verifyToken } from '@kokuin/token';
1
+ import { normalizeDID, verifyToken } from '@kokuin/token';
2
+ import { MAX_CAP_TTL_SECONDS } from '@kubun/store-delegation';
2
3
  /**
3
4
  * Verify a received capability grant and, when it is addressed to this device,
4
5
  * store it as a held row so the engine's auto-attach can reuse it on later
@@ -6,8 +7,9 @@ import { verifyToken } from '@kokuin/token';
6
7
  *
7
8
  * Returns `true` when the held row was inserted or materially changed (LWW
8
9
  * 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
10
+ * `false` when no row change occurred — verification failure, a subject that
11
+ * is not the signer, audience mismatch, missing required claims, a lifetime
12
+ * above `MAX_CAP_TTL_SECONDS`, LWW lost against an existing row, or an
11
13
  * idempotent re-broadcast with identical content. Never throws on a forged or
12
14
  * malformed token — it is skipped like an unverifiable broadcast entry.
13
15
  *
@@ -28,16 +30,56 @@ import { verifyToken } from '@kokuin/token';
28
30
  });
29
31
  return false;
30
32
  }
31
- // Only store grants addressed to this device.
32
- if (payload.aud !== selfDID) {
33
+ // Sender-bound: `sub` is the grantor the held row is filed under, and a
34
+ // signature only proves `iss`. Without this any co-member could file a row
35
+ // attributed to another peer — including one colliding with that peer's own
36
+ // `jti`, which is what makes the grantor-scoped revocation lookups
37
+ // unanswerable.
38
+ //
39
+ // The consequence is that **sub-delegation is not supported over this path**:
40
+ // a re-delegated grant, whose `sub` is the original grantor and whose `iss`
41
+ // is the re-delegator, is dropped here. Nothing mints one today
42
+ // (`grantWriteCapability` signs only for `identity.id`), but `checkCapability`
43
+ // does verify multi-hop chains, so enforcement would accept what this refuses
44
+ // to store. Supporting it means carrying and verifying the parent chain at
45
+ // this point, not relaxing the predicate — see
46
+ // `docs/agents/plans/backlog/sub-delegation-over-broadcast.md`.
47
+ if (normalizeDID(payload.sub) !== normalizeDID(payload.iss)) {
48
+ logger?.warn('received grant subject is not its signer, skipping', {
49
+ groupID
50
+ });
51
+ return false;
52
+ }
53
+ // Only store grants addressed to this device. Both sides are folded: a
54
+ // `did:peer:4` audience reaches us in long form on first contact and short
55
+ // form thereafter, and a raw compare would silently discard our own grant.
56
+ if (normalizeDID(payload.aud) !== normalizeDID(selfDID)) {
33
57
  return false;
34
58
  }
35
- if (payload.jti == null || payload.exp == null) {
36
- logger?.warn('received grant token missing jti/exp, skipping', {
59
+ // `iat` joins the guard because the TTL bound below is unanswerable without
60
+ // it, and an unanswerable bound is the hole this check exists to close. Both
61
+ // producers (`grantWriteCapability`, `KubunClient.mintWriteGrant`) stamp it.
62
+ if (payload.jti == null || payload.exp == null || payload.iat == null) {
63
+ logger?.warn('received grant token missing jti/exp/iat, skipping', {
37
64
  groupID
38
65
  });
39
66
  return false;
40
67
  }
68
+ // A capability accepted here can be revoked, and a co-member retains that
69
+ // revocation only for `MAX_CAP_TTL_SECONDS` past the revocation's own `iat` —
70
+ // it holds no copy of the capability to read an expiry from. Accepting a
71
+ // longer-lived grant would let it outlive its revocation and start being
72
+ // honored again. Skipped like any other unusable broadcast, not thrown: the
73
+ // sender is not necessarily this device's resolver.
74
+ if (payload.exp - payload.iat > MAX_CAP_TTL_SECONDS) {
75
+ logger?.warn('received grant lifetime exceeds the maximum, skipping', {
76
+ groupID,
77
+ jti: payload.jti,
78
+ ttl: payload.exp - payload.iat,
79
+ max: MAX_CAP_TTL_SECONDS
80
+ });
81
+ return false;
82
+ }
41
83
  const resource = typeof payload.res === 'string' ? payload.res : JSON.stringify(payload.res);
42
84
  const act = typeof payload.act === 'string' ? payload.act : JSON.stringify(payload.act);
43
85
  const changed = await delegationStore.addDelegationToken({
@@ -63,40 +105,43 @@ import { verifyToken } from '@kokuin/token';
63
105
  // `delegationTokenAdded` emission so subscribers see consistent ground
64
106
  // truth from the revocation backend on the same tick.
65
107
  if (changed) {
66
- const pending = await delegationStore.getPendingRevocationByJTI(payload.jti);
108
+ const pending = await delegationStore.getPendingRevocationByIssuer(payload.jti, payload.sub);
67
109
  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', {
110
+ // Pin the `verified_at` we send to subscribers to the same value the
111
+ // store writes; passing it explicitly avoids a millisecond-scale
112
+ // skew between the row and the emitted event under clock-tick
113
+ // boundaries.
114
+ const verifiedAt = Math.floor(Date.now() / 1000);
115
+ const flipped = await delegationStore.markRevocationVerified(payload.jti, payload.sub, {
116
+ cap_exp: payload.exp,
117
+ verified_at: verifiedAt
118
+ });
119
+ if (flipped && emitter != null) {
120
+ await emitter.emit('delegationTokenRevoked', {
92
121
  jti: payload.jti,
93
- pendingIss: pending.revoker_did,
94
- capGrantor: payload.sub,
122
+ grantor: payload.sub,
123
+ audience: payload.aud,
124
+ revokerDID: pending.revoker_did,
125
+ revokedAt: pending.revoked_iat,
126
+ verifiedAt,
127
+ capExp: payload.exp,
95
128
  groupID
96
129
  });
97
- await delegationStore.deletePendingRevocation(payload.jti);
98
130
  }
99
131
  }
132
+ // A `jti` names a capability only within one grantor's namespace, and it
133
+ // travels to the whole group — so a pending row from another author is
134
+ // either a forgery or a claim about a different capability. Either way this
135
+ // grant cannot cross-check it and it can never bind here.
136
+ const dropped = await delegationStore.deletePendingRevocationsFromOtherIssuers(payload.jti, payload.sub);
137
+ if (dropped > 0) {
138
+ logger?.warn('dropped pending revocations not authored by the arriving cap’s issuer', {
139
+ jti: payload.jti,
140
+ dropped,
141
+ capGrantor: payload.sub,
142
+ groupID
143
+ });
144
+ }
100
145
  }
101
146
  // Emit only when the held row was actually inserted or materially changed.
102
147
  // Idempotent re-broadcasts (LWW lost, or won with byte-identical content)
@@ -21,26 +21,29 @@ export type StoreReceivedRevocationParams = {
21
21
  * This helper does not emit; downstream subscription wiring lives elsewhere.
22
22
  */
23
23
  emitter?: P2PEventEmitter;
24
- /** Optional logger — debug on verification failure or malformed payload, warn on iss/grantor mismatch. */
24
+ /** Optional logger — debug on verification failure or malformed payload. */
25
25
  logger?: Logger;
26
26
  };
27
27
  /**
28
28
  * Verify a received revocation token and write its row to the P2P store.
29
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.
30
+ * Verified-on-arrival: when this device already holds a capability the
31
+ * record's own `iss` granted under that `jti`, the row is stored with
32
+ * `verified_at` populated so the hot-path `isRevoked` gate fires
33
+ * immediately. Otherwise the row is stored as pending (`verified_at` null)
34
+ * until a later `delegation:share` from that same issuer triggers a
35
+ * cross-check that flips it via `markRevocationVerified`.
36
+ *
37
+ * Holding someone else's capability under the same `jti` is not evidence
38
+ * that this record is forged a `jti` is namespaced by its grantor and any
39
+ * co-member can reuse one — so such a record is stored pending like any
40
+ * other unmatched one. It binds nothing: every enforcement read is scoped to
41
+ * the revoker, and `storeReceivedGrant` drops it when the issuer's own
42
+ * capability arrives.
39
43
  *
40
44
  * Returns `true` when the row was inserted or materially changed (LWW
41
45
  * 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.
46
+ * payload, or a lost/idempotent LWW upsert. Never throws on a forged or
47
+ * malformed token — it is skipped like an unverifiable broadcast entry.
45
48
  */
46
49
  export declare function storeReceivedRevocation(params: StoreReceivedRevocationParams): Promise<boolean>;
@@ -2,21 +2,24 @@ import { verifyToken } from '@kokuin/token';
2
2
  /**
3
3
  * Verify a received revocation token and write its row to the P2P store.
4
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.
5
+ * Verified-on-arrival: when this device already holds a capability the
6
+ * record's own `iss` granted under that `jti`, the row is stored with
7
+ * `verified_at` populated so the hot-path `isRevoked` gate fires
8
+ * immediately. Otherwise the row is stored as pending (`verified_at` null)
9
+ * until a later `delegation:share` from that same issuer triggers a
10
+ * cross-check that flips it via `markRevocationVerified`.
11
+ *
12
+ * Holding someone else's capability under the same `jti` is not evidence
13
+ * that this record is forged a `jti` is namespaced by its grantor and any
14
+ * co-member can reuse one — so such a record is stored pending like any
15
+ * other unmatched one. It binds nothing: every enforcement read is scoped to
16
+ * the revoker, and `storeReceivedGrant` drops it when the issuer's own
17
+ * capability arrives.
14
18
  *
15
19
  * Returns `true` when the row was inserted or materially changed (LWW
16
20
  * 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.
21
+ * payload, or a lost/idempotent LWW upsert. Never throws on a forged or
22
+ * malformed token — it is skipped like an unverifiable broadcast entry.
20
23
  */ export async function storeReceivedRevocation(params) {
21
24
  const { delegationStore, token, groupID, hlc, emitter, logger } = params;
22
25
  let payload;
@@ -37,19 +40,17 @@ import { verifyToken } from '@kokuin/token';
37
40
  return false;
38
41
  }
39
42
  const { jti, iss, iat } = payload;
40
- const cap = await delegationStore.getDelegationTokenByJTI(jti);
43
+ // Scoped to the record's own issuer. A co-member can mint a capability
44
+ // reusing a `jti` it saw broadcast, so asking for "the" capability named by a
45
+ // `jti` returns an arbitrary grantor's row — and the grantor's genuine
46
+ // revocation is the one with something to lose when the answer is wrong.
47
+ const cap = await delegationStore.getDelegationTokenByGrantorJTI({
48
+ jti,
49
+ grantor: iss
50
+ });
41
51
  let verifiedAt = null;
42
52
  let capExp = null;
43
53
  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
54
  verifiedAt = Math.floor(Date.now() / 1000);
54
55
  capExp = cap.exp;
55
56
  }
@@ -1,42 +1,25 @@
1
1
  import { Client } from '@enkaku/client';
2
2
  import type { Identity } from '@kokuin/token';
3
3
  import type { HubProtocol } from '@kumiai/hub-protocol';
4
- import { type ServerDIDMismatchObserver, type ServerDIDObserver } from './did-observing-transport.js';
5
4
  export type CreateHubClientOptions = {
6
5
  /**
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.
6
+ * The hub's DID, resolved per (re)connect by the spawning caller from the
7
+ * local hub row when it records one, otherwise from the hub's `/info`. Every
8
+ * token this client signs names it as the audience, and a hub always refuses a
9
+ * token that names nobody. Absent only when discovery failed: the connection
10
+ * is then attempted anyway and refused by the hub, which is what triggers the
11
+ * next discovery.
11
12
  */
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;
13
+ serverID?: string;
27
14
  };
28
15
  export type CreateHubClient = (hubURL: string, opts?: CreateHubClientOptions) => Promise<Client<HubProtocol>>;
29
16
  export type CreateHTTPHubClientParams = {
30
17
  identity: Identity;
31
18
  fetch?: typeof globalThis.fetch;
32
- serverID?: string;
33
19
  /**
34
- * Fired once per hub client when the first signed response from the server
35
- * arrives carrying `payload.iss = serverDID`. Subsequent responses do NOT
36
- * re-fire this hook, even if `iss` differs. Used by `HubConnectionPool` to
37
- * drive TOFU pinning into the hub manager. Per-call `onServerDID` from
38
- * `CreateHubClientOptions` overrides this factory-level default.
20
+ * Fallback audience for every client this factory builds, for a caller that
21
+ * knows the hub's DID up front. Per-call `serverID` wins when set.
39
22
  */
40
- onServerDID?: ServerDIDObserver;
23
+ serverID?: string;
41
24
  };
42
25
  export declare function createHTTPHubClient(params: CreateHTTPHubClientParams): CreateHubClient;
@@ -1,32 +1,15 @@
1
1
  import { Client } from '@enkaku/client';
2
2
  import { ClientTransport } from '@enkaku/http-fetch';
3
- import { DIDObservingTransport } from './did-observing-transport.js';
4
3
  export function createHTTPHubClient(params) {
5
4
  return async (hubURL, opts)=>{
6
- const inner = new ClientTransport({
5
+ const transport = new ClientTransport({
7
6
  url: hubURL,
8
7
  fetch: params.fetch
9
8
  });
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
9
  return new Client({
27
10
  transport,
28
11
  identity: params.identity,
29
- serverID: params.serverID
12
+ serverID: opts?.serverID ?? params.serverID
30
13
  });
31
14
  };
32
15
  }
@@ -1,17 +1,26 @@
1
- import type { Client } from '@enkaku/client';
1
+ import { type Client } from '@enkaku/client';
2
2
  import type { Logger } from '@kubun/logger';
3
- import type { HubProtocol } from '@kumiai/hub-protocol';
4
- import type { HubLike } from '@kumiai/hub-tunnel';
3
+ import { type HubProtocol } from '@kumiai/hub-protocol';
4
+ import type { HubReceiveSubscription, LogHub, MailboxHub } from '@kumiai/hub-tunnel';
5
5
  export type CreateHubLikeParams = {
6
6
  /**
7
7
  * Builds a fresh `Client<HubProtocol>` bound to one hub URL + device identity.
8
8
  * Called once on the first connect and again on every reconnect. Injected
9
9
  * rather than constructed here so the adapter is testable against a fake
10
- * client without standing up HTTP.
10
+ * client without standing up HTTP. The signal is the adapter's own: a factory
11
+ * that reaches the network (hub discovery) must abandon it on dispose, or
12
+ * teardown waits out a request to a hub that is not answering.
11
13
  */
12
- createClient: () => Client<HubProtocol> | Promise<Client<HubProtocol>>;
14
+ createClient: (signal: AbortSignal) => Client<HubProtocol> | Promise<Client<HubProtocol>>;
13
15
  /** Authenticated DID of this device — drains receive and stamps publishes. */
14
16
  localDID: string;
17
+ /**
18
+ * Called when the hub refused a request with `ACCESS_DENIED`, awaited before
19
+ * the reconnect backoff so anything it corrects is in place for the next dial.
20
+ * That code conflates a refused audience with a refused caller, so which one it
21
+ * was is the handler's to work out, not the adapter's.
22
+ */
23
+ onAccessDenied?: () => Promise<void>;
15
24
  logger?: Logger;
16
25
  ackFlushMs?: number;
17
26
  ackFlushMax?: number;
@@ -19,6 +28,48 @@ export type CreateHubLikeParams = {
19
28
  backoffMaxMs?: number;
20
29
  backoffJitter?: number;
21
30
  };
22
- export declare function createHubLike(params: CreateHubLikeParams): HubLike & {
31
+ /** What a subscriber may narrow its own drain to, beyond its DID. */
32
+ export type DeviceHubReceiveOptions = {
33
+ /**
34
+ * Deliver only frames the hub attested as sent by this DID.
35
+ *
36
+ * The narrowing happens on the fan-out, before anything is queued and long
37
+ * before anything is decrypted, which is what a directed session needs: MLS
38
+ * consumes a per-sender ratchet generation on open, so a session that opens a
39
+ * frame belonging to another has DESTROYED it — the session it was for waits
40
+ * out its idle timeout with nothing raised anywhere.
41
+ */
42
+ senderDID?: string;
43
+ /**
44
+ * Deliver only frames published to this topic.
45
+ *
46
+ * The tunnel transport already asks for this (`hub.receive(localDID, {
47
+ * topicID: receiveTopicID })`), and until it was honoured here the request
48
+ * went nowhere. It has to be applied on the fan-out for the same reason
49
+ * `senderDID` does: the encrypting transport decrypts a frame before the
50
+ * topic demux one layer in ever sees it, so a sink handed another lane's
51
+ * frame has consumed a ratchet generation and destroyed it.
52
+ */
53
+ topicID?: string;
54
+ };
55
+ /**
56
+ * The device hub as this package uses it: a `MailboxHub` whose `receive` also
57
+ * takes {@link DeviceHubReceiveOptions}. Declared because the tunnel transport
58
+ * takes the narrower `MailboxHub`, so a caller that needs a filtered drain has
59
+ * to hold this type to ask for one.
60
+ */
61
+ export type DeviceHub = MailboxHub & {
62
+ receive: (subscriberDID: string, options?: DeviceHubReceiveOptions) => HubReceiveSubscription;
63
+ };
64
+ /**
65
+ * The reconnecting adapter itself, as its owner holds it: a hub, plus the two
66
+ * things only whoever built the connection can do — retry it now, and close it.
67
+ * Kept off {@link DeviceHub} because that is the surface a tunnel session
68
+ * consumes, and a session has no connection of its own to retry.
69
+ */
70
+ export type ReconnectingDeviceHub = LogHub & DeviceHub & {
71
+ /** Try the hub now rather than when the backoff says so. */
72
+ retryNow: () => Promise<boolean>;
23
73
  dispose: () => Promise<void>;
24
74
  };
75
+ export declare function createHubLike(params: CreateHubLikeParams): ReconnectingDeviceHub;