@vibesdotdev/kernel-rpc 1.0.0-rc.3 → 1.0.0-rc.7

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 (130) hide show
  1. package/dist/cli/daemon/runtime.daemon.serve.impl.consumer.d.ts +2 -2
  2. package/dist/cli/daemon/runtime.daemon.serve.impl.consumer.d.ts.map +1 -1
  3. package/dist/cli/daemon/runtime.daemon.serve.impl.consumer.js +6 -0
  4. package/dist/cli/daemon/runtime.daemon.serve.impl.consumer.js.map +1 -1
  5. package/dist/core/helper.d.ts +1 -1
  6. package/dist/core/helper.d.ts.map +1 -1
  7. package/dist/core/helper.js +14 -0
  8. package/dist/core/helper.js.map +1 -1
  9. package/dist/daemon/client/contracts1/connection.d.ts +6 -2
  10. package/dist/daemon/client/contracts1/connection.d.ts.map +1 -1
  11. package/dist/daemon/client/contracts1/connection.js +43 -11
  12. package/dist/daemon/client/contracts1/connection.js.map +1 -1
  13. package/dist/daemon/client/contracts1/responses.d.ts.map +1 -1
  14. package/dist/daemon/client/contracts1/responses.js +3 -1
  15. package/dist/daemon/client/contracts1/responses.js.map +1 -1
  16. package/dist/daemon/client/contracts2/unix-client.d.ts.map +1 -1
  17. package/dist/daemon/client/contracts2/unix-client.js +2 -2
  18. package/dist/daemon/client/contracts2/unix-client.js.map +1 -1
  19. package/dist/daemon/commands/data/write.d.ts.map +1 -1
  20. package/dist/daemon/commands/data/write.js +118 -3
  21. package/dist/daemon/commands/data/write.js.map +1 -1
  22. package/dist/daemon/commands/invoke/tool.d.ts +1 -1
  23. package/dist/daemon/commands/invoke/tool.d.ts.map +1 -1
  24. package/dist/daemon/commands/invoke/tool.js +21 -4
  25. package/dist/daemon/commands/invoke/tool.js.map +1 -1
  26. package/dist/daemon/commands/invoke.d.ts.map +1 -1
  27. package/dist/daemon/commands/invoke.js +3 -1
  28. package/dist/daemon/commands/invoke.js.map +1 -1
  29. package/dist/daemon/hosting/execution/daemon-execution-host.consumer.d.ts.map +1 -1
  30. package/dist/daemon/hosting/execution/daemon-execution-host.consumer.js +6 -1
  31. package/dist/daemon/hosting/execution/daemon-execution-host.consumer.js.map +1 -1
  32. package/dist/daemon/hosting/execution/session-workspace-locator.d.ts +96 -0
  33. package/dist/daemon/hosting/execution/session-workspace-locator.d.ts.map +1 -0
  34. package/dist/daemon/hosting/execution/session-workspace-locator.js +162 -0
  35. package/dist/daemon/hosting/execution/session-workspace-locator.js.map +1 -0
  36. package/dist/daemon/hosting/execution/workspace-execution-hosts.d.ts +60 -0
  37. package/dist/daemon/hosting/execution/workspace-execution-hosts.d.ts.map +1 -0
  38. package/dist/daemon/hosting/execution/workspace-execution-hosts.js +134 -0
  39. package/dist/daemon/hosting/execution/workspace-execution-hosts.js.map +1 -0
  40. package/dist/daemon/hosting/execution/workspace-routing-connection.d.ts +60 -0
  41. package/dist/daemon/hosting/execution/workspace-routing-connection.d.ts.map +1 -0
  42. package/dist/daemon/hosting/execution/workspace-routing-connection.js +153 -0
  43. package/dist/daemon/hosting/execution/workspace-routing-connection.js.map +1 -0
  44. package/dist/daemon/runtime/io/execution.js +2 -2
  45. package/dist/daemon/runtime/io/execution.js.map +1 -1
  46. package/dist/daemon/runtime/lifecycle/shutdown.d.ts.map +1 -1
  47. package/dist/daemon/runtime/lifecycle/shutdown.js +1 -0
  48. package/dist/daemon/runtime/lifecycle/shutdown.js.map +1 -1
  49. package/dist/daemon/runtime/lifecycle/startup.d.ts.map +1 -1
  50. package/dist/daemon/runtime/lifecycle/startup.js +18 -1
  51. package/dist/daemon/runtime/lifecycle/startup.js.map +1 -1
  52. package/dist/daemon/runtime/state.d.ts +3 -0
  53. package/dist/daemon/runtime/state.d.ts.map +1 -1
  54. package/dist/daemon/runtime/state.js +2 -0
  55. package/dist/daemon/runtime/state.js.map +1 -1
  56. package/dist/daemon/services/api/rpc-root.d.ts +1 -0
  57. package/dist/daemon/services/api/rpc-root.d.ts.map +1 -1
  58. package/dist/daemon/services/api/rpc-root.js +1 -0
  59. package/dist/daemon/services/api/rpc-root.js.map +1 -1
  60. package/dist/schemas/contracts/cli/daemon-execution.worker.entry.d.ts +16 -65
  61. package/dist/schemas/contracts/cli/daemon-execution.worker.entry.d.ts.map +1 -1
  62. package/dist/schemas/contracts/core/helper.d.ts +23 -0
  63. package/dist/schemas/contracts/core/helper.d.ts.map +1 -1
  64. package/dist/schemas/contracts/core/helper.js.map +1 -1
  65. package/dist/schemas/contracts/daemon/commands/write.d.ts +1 -0
  66. package/dist/schemas/contracts/daemon/commands/write.d.ts.map +1 -1
  67. package/dist/schemas/contracts/daemon/commands/write.js +2 -1
  68. package/dist/schemas/contracts/daemon/commands/write.js.map +1 -1
  69. package/dist/schemas/contracts/daemon/hosting/contracts1/daemon-execution-host.consumer.d.ts +6 -0
  70. package/dist/schemas/contracts/daemon/hosting/contracts1/daemon-execution-host.consumer.d.ts.map +1 -1
  71. package/dist/schemas/contracts/daemon/hosting/contracts1/daemon-execution-host.consumer.js.map +1 -1
  72. package/dist/schemas/contracts/daemon/hosting/contracts1/daemon.consumer.d.ts +3 -3
  73. package/dist/schemas/contracts/daemon/hosting/contracts1/daemon.consumer.d.ts.map +1 -1
  74. package/dist/schemas/contracts/daemon/hosting/contracts1/daemon.consumer.js.map +1 -1
  75. package/dist/schemas/contracts/daemon/hosting/contracts1/execution-host.schemas.d.ts +36 -245
  76. package/dist/schemas/contracts/daemon/hosting/contracts1/execution-host.schemas.d.ts.map +1 -1
  77. package/dist/schemas/contracts/daemon/hosting/contracts1/execution-host.schemas.js +2 -2
  78. package/dist/schemas/contracts/daemon/hosting/contracts1/execution-host.schemas.js.map +1 -1
  79. package/dist/schemas/contracts/transports/messageport/messageport-listener.d.ts +2 -0
  80. package/dist/schemas/contracts/transports/messageport/messageport-listener.d.ts.map +1 -1
  81. package/dist/schemas/contracts/transports/relay/wire.d.ts +24 -0
  82. package/dist/schemas/contracts/transports/relay/wire.d.ts.map +1 -1
  83. package/dist/schemas/contracts/transports/relay/wire.js +17 -1
  84. package/dist/schemas/contracts/transports/relay/wire.js.map +1 -1
  85. package/dist/schemas/daemon/daemon-client.d.ts +10 -1
  86. package/dist/schemas/daemon/daemon-client.d.ts.map +1 -1
  87. package/dist/schemas/daemon/daemon-client.js +2 -0
  88. package/dist/schemas/daemon/daemon-client.js.map +1 -1
  89. package/dist/schemas/invoke.d.ts +2 -0
  90. package/dist/schemas/invoke.d.ts.map +1 -1
  91. package/dist/transports/http/http-implementation.js +1 -1
  92. package/dist/transports/http/http-implementation.js.map +1 -1
  93. package/dist/transports/relay/broker/index.d.ts +10 -0
  94. package/dist/transports/relay/broker/index.d.ts.map +1 -0
  95. package/dist/transports/relay/broker/index.js +10 -0
  96. package/dist/transports/relay/broker/index.js.map +1 -0
  97. package/dist/transports/relay/broker/relay-broker.d.ts +352 -0
  98. package/dist/transports/relay/broker/relay-broker.d.ts.map +1 -0
  99. package/dist/transports/relay/broker/relay-broker.js +1417 -0
  100. package/dist/transports/relay/broker/relay-broker.js.map +1 -0
  101. package/dist/transports/relay/endpoint.d.ts +36 -0
  102. package/dist/transports/relay/endpoint.d.ts.map +1 -0
  103. package/dist/transports/relay/endpoint.js +54 -0
  104. package/dist/transports/relay/endpoint.js.map +1 -0
  105. package/dist/transports/relay/index.d.ts +2 -1
  106. package/dist/transports/relay/index.d.ts.map +1 -1
  107. package/dist/transports/relay/index.js +3 -1
  108. package/dist/transports/relay/index.js.map +1 -1
  109. package/dist/transports/relay/listener/relay-caller.d.ts +16 -0
  110. package/dist/transports/relay/listener/relay-caller.d.ts.map +1 -0
  111. package/dist/transports/relay/listener/relay-caller.js +34 -0
  112. package/dist/transports/relay/listener/relay-caller.js.map +1 -0
  113. package/dist/transports/relay/listener/relay-consumers.d.ts +2 -1
  114. package/dist/transports/relay/listener/relay-consumers.d.ts.map +1 -1
  115. package/dist/transports/relay/listener/relay-consumers.js +15 -10
  116. package/dist/transports/relay/listener/relay-consumers.js.map +1 -1
  117. package/dist/transports/relay/listener/relay-listen-session.d.ts.map +1 -1
  118. package/dist/transports/relay/listener/relay-listen-session.js +21 -1
  119. package/dist/transports/relay/listener/relay-listen-session.js.map +1 -1
  120. package/dist/transports/relay/testing/broker-routing.d.ts +9 -0
  121. package/dist/transports/relay/testing/broker-routing.d.ts.map +1 -1
  122. package/dist/transports/relay/testing/broker-routing.js +13 -1
  123. package/dist/transports/relay/testing/broker-routing.js.map +1 -1
  124. package/dist/transports/relay/testing/fake-broker.js +1 -1
  125. package/dist/transports/relay/testing/fake-broker.js.map +1 -1
  126. package/dist/transports/relay/wire.d.ts +18 -1
  127. package/dist/transports/relay/wire.d.ts.map +1 -1
  128. package/dist/transports/relay/wire.js +22 -1
  129. package/dist/transports/relay/wire.js.map +1 -1
  130. package/package.json +59 -22
@@ -0,0 +1,1417 @@
1
+ /**
2
+ * Relay broker — the per-host state machine between relay consumers and the
3
+ * daemon's outbound socket. One instance per machine, keyed by hostId.
4
+ *
5
+ * This is transport mechanics only (kernel-rpc). Durable Object custody,
6
+ * host enrollment, presence records and consumer admission policy stay with
7
+ * the composing app, injected through {@link RelayBrokerPorts}: the app's DO
8
+ * shell owns storage sockets' host bindings, `hostAdmission` fences daemon
9
+ * acceptance against the app's host records, `onPresence` records daemon
10
+ * reachability, and `report` receives the broker's operator events.
11
+ *
12
+ * CAPABILITY-TOKEN MODEL (iteration 2 hardening):
13
+ *
14
+ * A. __relay_handshake mints a crypto-random consumerId AND a secret session-token
15
+ * per consumer. Both are returned to the relay client in the handshake response.
16
+ * consumerId → sessionToken is stored in durable storage (survives hibernation).
17
+ *
18
+ * B. Every consumer call (rpc, stream-open, stream/write, stream/close) MUST carry
19
+ * consumerId + session-token. The DO validates the pair FAIL-CLOSED: missing,
20
+ * unknown consumerId, or token mismatch → 4xx. Never silently routed.
21
+ *
22
+ * C. Routes forward consumerId + session-token from the consumer request to the DO
23
+ * (they are NOT dropped by the SvelteKit worker routes).
24
+ *
25
+ * D. channelId is DO-minted (random), stored as channelId → owning consumerId.
26
+ * write/close validate channelId ownership against the validated consumerId,
27
+ * fail-closed when consumerId absent.
28
+ *
29
+ * E. Per-consumer IsolatedConsumerTable on the daemon listen session (see
30
+ * relay-listen-session.ts): refs are crypto-random; a ref from consumer X is
31
+ * unresolvable for consumer Y. Binary frames require consumerId and are dropped
32
+ * if the channel is not owned by that consumer.
33
+ *
34
+ * F. Empty methods set DENIES ALL (relay-listen-session.ts): the Proxy.get trap
35
+ * and invokeRemote pre-send guard both treat size===0 as deny-all.
36
+ *
37
+ * G. Per-consumer reverse channels keyed by consumerId. Opening one NEVER evicts
38
+ * another consumer's channel. reverse-reply requires consumerId+token validated.
39
+ * Reverse callbacks cannot resolve a pending entry for a different consumer.
40
+ *
41
+ * H. Bounds: reverse streams capped at MAX_REVERSE_STREAMS; reconnect rate-limit
42
+ * persisted to durable storage (survives hibernation); pending entries get
43
+ * timeout + cleanup.
44
+ *
45
+ * §3.7 CANONICAL broker<->daemon WS protocol:
46
+ * - Text frames are JSON ENVELOPES:
47
+ * broker->daemon: { type:'rpc_request', consumerId, request }
48
+ * daemon->broker: { type:'rpc_response', consumerId, response }
49
+ * reverse leg : { type:'rpc_request', consumerId, request: RpcInvoke }
50
+ * keepalive : { type:'keepalive' }
51
+ * (a daemon->broker keepalive is auto-answered AT THE EDGE
52
+ * with { type:'keepalive_ack' } without waking this DO —
53
+ * see KEEPALIVE_AUTO_RESPONSE_* below)
54
+ * - Binary frames: [36-byte channelId][1-byte frameType][payload].
55
+ */
56
+ import { RpcStreamSchema } from '@vibesdotdev/kernel-rpc';
57
+ import { CapabilityError, CaughtFailureSchema, constantTimeEqual } from '@vibesdotdev/kernel';
58
+ import { JsonValueSchema } from '@vibesdotdev/kernel/schemas/json';
59
+ import { z } from 'zod/v4';
60
+ import { RelayDaemonHandshakeResultSchema, RelayJsonMessageSchema, RelayConsumerCloseBodySchema, RelayConsumerClosedMessageSchema, RelayRpcRequestMessageSchema, RelayRpcResponseMessageSchema, encodeBinaryFrame, decodeBinaryFrame, encodeBase64, decodeBase64, daemonReadsVerifiedCaller, RELAY_DAEMON_CAPS_HEADER, RELAY_SSE_FRAME_EVENT, RELAY_VERIFIED_ORG_HEADER, RELAY_VERIFIED_USER_HEADER } from "../wire.js";
61
+ const FRAME_DATA = 0x00;
62
+ const FRAME_CLOSE = 0x01;
63
+ /** Stream write/close bodies as presented; every field is checked before use. */
64
+ const StreamWriteBodySchema = z.looseObject({
65
+ channelId: z.string().optional(),
66
+ consumerId: z.string().optional(),
67
+ sessionToken: z.string().optional(),
68
+ data: z.string().optional()
69
+ });
70
+ const StreamCloseBodySchema = z.looseObject({
71
+ channelId: z.string().optional(),
72
+ consumerId: z.string().optional(),
73
+ sessionToken: z.string().optional()
74
+ });
75
+ /** The verified caller a broker-internal request presents; only this Worker sets these headers. */
76
+ function verifiedCallerFromHeaders(request) {
77
+ const userId = request.headers.get(RELAY_VERIFIED_USER_HEADER)?.trim();
78
+ if (!userId)
79
+ return null;
80
+ const caller = { userId };
81
+ const orgId = request.headers.get(RELAY_VERIFIED_ORG_HEADER)?.trim();
82
+ if (orgId)
83
+ caller.orgId = orgId;
84
+ return caller;
85
+ }
86
+ /** The capability names its admitted daemon; closed rows retain only close-retry authority until expiry. */
87
+ const StoredCapabilityTokenSchema = z.object({
88
+ sessionToken: z.string().min(1), expiresAt: z.number().finite(),
89
+ daemonConnectionId: z.uuid(),
90
+ state: z.enum(['open', 'closing', 'closed']).optional(),
91
+ /** The user the handshake was authorized for (D1); every later request must be that user. */
92
+ userId: z.string().min(1).optional(),
93
+ /** The organization that user was authorized in (D1), for an organization-owned host. */
94
+ orgId: z.string().min(1).optional()
95
+ });
96
+ const ConsumerCloseInput = RelayConsumerCloseBodySchema;
97
+ const SSE_FRAME_EVENT = RELAY_SSE_FRAME_EVENT;
98
+ const KEEPALIVE_ALARM_MS = 90_000;
99
+ // Durable flag: "a keepalive probe is outstanding, awaiting the daemon's echo."
100
+ // See `alarm()` — a healthy-but-idle daemon gets ONE probe-and-wait cycle before
101
+ // the DO declares it dead, instead of the alarm evicting on silence alone.
102
+ const KEEPALIVE_PROBE_PENDING_KEY = 'relay:keepaliveProbePending';
103
+ // Persisted keepalive due time. `keepaliveDueAt` is in-memory and is null after
104
+ // a hibernation wake; this key lets alarm()/armAlarm restore the cadence.
105
+ const KEEPALIVE_DUE_AT_KEY = 'relay:keepaliveDueAt';
106
+ // Hibernation auto-response for the §3.7 keepalive frame. The EXACT text the
107
+ // daemon emits (`JSON.stringify({ type: 'keepalive' })`, no whitespace) is
108
+ // answered at the edge without waking this DO, and the runtime stamps the
109
+ // socket (`getWebSocketAutoResponseTimestamp`) so `alarm()` can read liveness
110
+ // instead of paying a webSocketMessage wake + two storage writes per frame.
111
+ //
112
+ // The reply is deliberately NOT another `{"type":"keepalive"}`: the daemon's
113
+ // RelayListenSession echoes every keepalive it receives
114
+ // (runtime-rpc/src/relay/relay-listen-session.ts), so a symmetric pair would
115
+ // ping-pong between the edge and the daemon forever. `keepalive_ack` falls
116
+ // through the daemon's dispatch untouched (unknown types are ignored, never
117
+ // echoed). Protocol contract relied on here (framework #463 owns the client):
118
+ // 1. the daemon sends the exact bytes KEEPALIVE_AUTO_RESPONSE_REQUEST;
119
+ // 2. the daemon ignores KEEPALIVE_AUTO_RESPONSE_REPLY and never echoes it.
120
+ const KEEPALIVE_AUTO_RESPONSE_REQUEST = JSON.stringify({ type: 'keepalive' });
121
+ const KEEPALIVE_AUTO_RESPONSE_REPLY = JSON.stringify({ type: 'keepalive_ack' });
122
+ // An auto-answered keepalive inside the last window is proof of life with no
123
+ // probe; one inside TWO windows still vetoes an eviction (a probe that landed
124
+ // late). Older than that, the socket is treated as silent.
125
+ const KEEPALIVE_LIVENESS_WINDOW_MS = KEEPALIVE_ALARM_MS;
126
+ const KEEPALIVE_EVICT_STALE_MS = 2 * KEEPALIVE_ALARM_MS;
127
+ // Capability tokens are a backstop-expiry keyspace. The PRIMARY reclaim is
128
+ // webSocketClose (a dropped daemon kills every consumer session for this host),
129
+ // but a long-lived daemon with churning consumers never closes, so each token
130
+ // also carries an expiry that the keepalive alarm + a mint-time opportunistic
131
+ // sweep reclaim. 12h comfortably outlives any interactive relay session.
132
+ const CAPABILITY_TOKEN_TTL_MS = 12 * 60 * 60_000;
133
+ const MAX_REVERSE_STREAMS = 32;
134
+ // Cap concurrent FORWARD (consumer→daemon) SSE channels too. Without this an
135
+ // authenticated consumer could open unbounded GET /stream channels, each pinning
136
+ // a ReadableStream controller + encoder in this per-host DO's heap.
137
+ const MAX_FORWARD_STREAMS = 64;
138
+ const MAX_RECONNECT_PER_MINUTE = 10;
139
+ // Handshake, stream admission and close-control policy. Ordinary invokes follow
140
+ // their consumer and native request lifetime, including long PTY exit waits.
141
+ const PENDING_ENTRY_TIMEOUT_MS = 300_000;
142
+ // Wake coalescing for a disconnected handshake. The per-host DO is the EXISTING
143
+ // durable owner for this host, so the claim lives in its own storage — not a
144
+ // new ledger, table, or queue. A dispatch marker younger than this window
145
+ // suppresses a second provider wake attempt (a concurrent handshake, or a retry
146
+ // that lands while the placement is still booting); a failed dispatch releases
147
+ // it explicitly and an accepted daemon socket clears it.
148
+ const WAKE_CLAIM_KEY = 'relay:wakeClaimedAt';
149
+ const WAKE_CLAIM_TTL_MS = 90_000;
150
+ /*
151
+ * Plain class (classic Durable Object constructor shape), NOT `extends DurableObject` from
152
+ * `cloudflare:workers`: every consumer talks to the stub via `stub.fetch()`,
153
+ * and the value import of `cloudflare:workers` cannot execute outside workerd.
154
+ * The composing app's DO shell constructs one broker per DO instance and
155
+ * delegates `fetch`/`alarm`/`webSocket*` to it.
156
+ */
157
+ export class RelayBroker {
158
+ ctx;
159
+ ports;
160
+ constructor(ctx, ports) {
161
+ this.ctx = ctx;
162
+ this.ports = ports;
163
+ this.registerKeepaliveAutoResponse();
164
+ }
165
+ /** One operator event to the composing host, when it listens. */
166
+ report(event) {
167
+ this.ports.report?.(event);
168
+ }
169
+ /** Close a daemon socket; a failure is reported, never fatal to cleanup. */
170
+ closeSocket(ws, code, reason) {
171
+ try {
172
+ ws.close(code, reason);
173
+ }
174
+ catch (caught) {
175
+ this.report({ type: 'socket-close-failed', error: CaughtFailureSchema.parse(caught) });
176
+ }
177
+ }
178
+ /** Close one consumer stream; a failure is reported, never fatal to cleanup. */
179
+ closeStream(stream) {
180
+ try {
181
+ stream.controller.close();
182
+ }
183
+ catch (caught) {
184
+ this.report({ type: 'stream-close-failed', error: CaughtFailureSchema.parse(caught) });
185
+ }
186
+ }
187
+ /** Send one frame; false when the socket refused it (reported, never fatal). */
188
+ sendToSocket(ws, message) {
189
+ try {
190
+ ws.send(message);
191
+ return true;
192
+ }
193
+ catch (caught) {
194
+ this.report({ type: 'socket-send-failed', error: CaughtFailureSchema.parse(caught) });
195
+ return false;
196
+ }
197
+ }
198
+ /**
199
+ * Register the keepalive auto-response at construction (the Hibernation API
200
+ * pattern: re-applied on every DO wake). Guarded so the class still
201
+ * constructs where the API or the pair factory is absent.
202
+ */
203
+ registerKeepaliveAutoResponse() {
204
+ const createPair = this.ports.createAutoResponsePair;
205
+ if (!createPair)
206
+ return;
207
+ if (this.ctx.setWebSocketAutoResponse === undefined)
208
+ return;
209
+ try {
210
+ this.ctx.setWebSocketAutoResponse(createPair(KEEPALIVE_AUTO_RESPONSE_REQUEST, KEEPALIVE_AUTO_RESPONSE_REPLY));
211
+ }
212
+ catch (caught) {
213
+ this.report({
214
+ type: 'keepalive-auto-response-failed',
215
+ error: CaughtFailureSchema.parse(caught)
216
+ });
217
+ }
218
+ }
219
+ /**
220
+ * Latest edge-answered keepalive across the given sockets (ms epoch), or
221
+ * null when the runtime has none / does not expose the timestamp.
222
+ */
223
+ lastKeepaliveAnsweredAt(sockets) {
224
+ const read = this.ctx.getWebSocketAutoResponseTimestamp;
225
+ if (read === undefined)
226
+ return null;
227
+ let latest = null;
228
+ for (const ws of sockets) {
229
+ let at = null;
230
+ try {
231
+ at = read.call(this.ctx, ws);
232
+ }
233
+ catch (caught) {
234
+ this.report({
235
+ type: 'keepalive-timestamp-failed',
236
+ error: CaughtFailureSchema.parse(caught)
237
+ });
238
+ at = null;
239
+ }
240
+ const ms = at instanceof Date ? at.getTime() : null;
241
+ if (ms != null && Number.isFinite(ms) && (latest == null || ms > latest))
242
+ latest = ms;
243
+ }
244
+ return latest;
245
+ }
246
+ pending = new Map();
247
+ /** Next keepalive due time; multiplexed onto the single DO alarm with pending expiries. */
248
+ keepaliveDueAt = null;
249
+ /** The alarm currently scheduled, so armAlarm only pulls it earlier. */
250
+ nextAlarmAt = null;
251
+ streams = new Map();
252
+ /** G: per-consumer reverse channels; never evicts another consumer. */
253
+ reverseStreams = new Map();
254
+ hasDaemonSocket = false;
255
+ retired = false;
256
+ /**
257
+ * In-memory mirror of `WAKE_CLAIM_KEY`. The check-and-set below is
258
+ * synchronous, so two concurrent claims cannot both observe "unclaimed"
259
+ * across the storage read/write await boundary.
260
+ */
261
+ wakeClaimedAt = null;
262
+ async isRetired() {
263
+ const persisted = await this.ctx.storage.get('retired');
264
+ this.retired = this.retired || persisted === true;
265
+ return this.retired;
266
+ }
267
+ /** Persist the fence before fallible cleanup. Retrying never reopens admission. */
268
+ async retire(hostId) {
269
+ this.retired = true;
270
+ await this.ctx.storage.put('retired', true);
271
+ for (const ws of this.ctx.getWebSockets('daemon')) {
272
+ this.closeSocket(ws, 4003, 'host retired');
273
+ }
274
+ for (const { reject } of this.pending.values()) {
275
+ reject({ code: 'RPC_DISCONNECTED', message: 'host retired' });
276
+ }
277
+ this.pending.clear();
278
+ for (const channels of [this.streams, this.reverseStreams]) {
279
+ for (const stream of channels.values()) {
280
+ this.closeStream(stream);
281
+ }
282
+ channels.clear();
283
+ }
284
+ this.hasDaemonSocket = false;
285
+ this.wakeClaimedAt = null;
286
+ this.nextAlarmAt = null;
287
+ await this.persistKeepaliveDueAt(null);
288
+ await this.clearAllCapabilityTokens();
289
+ await this.ctx.storage.delete(WAKE_CLAIM_KEY);
290
+ await this.ctx.storage.deleteAlarm();
291
+ await this.writePresence(hostId, false);
292
+ return new Response(null, { status: 204 });
293
+ }
294
+ expireDuePendings(now) {
295
+ for (const [key, entry] of this.pending.entries()) {
296
+ if (entry.expiresAt === null || entry.expiresAt > now)
297
+ continue;
298
+ this.pending.delete(key);
299
+ if (entry.kind !== 'stream') {
300
+ entry.reject({ code: 'RPC_DISCONNECTED', message: 'request timed out' });
301
+ }
302
+ }
303
+ }
304
+ /** Daemon sockets that still accept frames (hibernation + test fakes). */
305
+ openDaemonSockets() {
306
+ return this.ctx.getWebSockets('daemon').filter((ws) => {
307
+ const readyState = ws.readyState;
308
+ if (readyState === 2 || readyState === 3)
309
+ return false;
310
+ // Test fakes record their close code instead of a readyState.
311
+ const closed = z
312
+ .object({ closeCode: z.number().nullable() })
313
+ .safeParse(ws);
314
+ return !closed.success || closed.data.closeCode == null;
315
+ });
316
+ }
317
+ socketForOwner(daemonConnectionId) {
318
+ return this.openDaemonSockets().find(socket => readSocketMeta(socket)?.connectionId === daemonConnectionId);
319
+ }
320
+ async socketForConsumer(consumerId) {
321
+ const stored = await this.readStoredToken(consumerId);
322
+ return stored ? this.socketForOwner(stored.daemonConnectionId) : undefined;
323
+ }
324
+ /**
325
+ * Restore the keepalive due time after hibernation. Memory is empty on a
326
+ * wake; storage survives; failing that, daemon sockets and their
327
+ * hibernation attachments still prove a probe cadence is required.
328
+ */
329
+ async resolvedKeepaliveDueAt(now, sockets) {
330
+ if (this.keepaliveDueAt == null) {
331
+ const persisted = z
332
+ .number()
333
+ .finite()
334
+ .safeParse(await this.ctx.storage.get(KEEPALIVE_DUE_AT_KEY));
335
+ if (persisted.success) {
336
+ this.keepaliveDueAt = persisted.data;
337
+ }
338
+ }
339
+ if (this.keepaliveDueAt != null)
340
+ return this.keepaliveDueAt;
341
+ const daemon = sockets ?? this.openDaemonSockets();
342
+ if (daemon.length === 0)
343
+ return null;
344
+ let earliest = null;
345
+ for (const ws of daemon) {
346
+ const meta = readSocketMeta(ws);
347
+ if (meta == null)
348
+ continue;
349
+ const due = meta.acceptedAt + KEEPALIVE_ALARM_MS;
350
+ if (earliest == null || due < earliest)
351
+ earliest = due;
352
+ }
353
+ // Hibernated daemon socket with no persisted due and no attachment:
354
+ // the alarm that woke us is the keepalive tick.
355
+ return earliest ?? now;
356
+ }
357
+ async persistKeepaliveDueAt(at) {
358
+ this.keepaliveDueAt = at;
359
+ if (at == null)
360
+ await this.ctx.storage.delete(KEEPALIVE_DUE_AT_KEY);
361
+ else
362
+ await this.ctx.storage.put(KEEPALIVE_DUE_AT_KEY, at);
363
+ }
364
+ async armAlarm() {
365
+ const now = Date.now();
366
+ const candidates = [];
367
+ const keepaliveAt = await this.resolvedKeepaliveDueAt(now);
368
+ if (keepaliveAt != null)
369
+ candidates.push(keepaliveAt);
370
+ for (const entry of this.pending.values()) {
371
+ if (entry.expiresAt !== null)
372
+ candidates.push(entry.expiresAt);
373
+ }
374
+ if (candidates.length === 0) {
375
+ this.nextAlarmAt = null;
376
+ await this.ctx.storage.deleteAlarm();
377
+ return;
378
+ }
379
+ const at = Math.min(...candidates);
380
+ if (this.nextAlarmAt != null && this.nextAlarmAt <= at)
381
+ return;
382
+ this.nextAlarmAt = at;
383
+ await this.ctx.storage.setAlarm(at);
384
+ }
385
+ waitForPending(key, { timeoutMs, kind = 'rpc', signal, daemonConnectionId }) {
386
+ const existing = this.pending.get(key)?.answer;
387
+ if (existing)
388
+ return existing;
389
+ const outcome = Promise.withResolvers();
390
+ const release = () => signal?.removeEventListener('abort', aborted);
391
+ const aborted = () => {
392
+ this.pending.delete(key);
393
+ release();
394
+ outcome.reject({ code: 'RPC_DISCONNECTED', message: 'consumer request closed' });
395
+ };
396
+ this.pending.set(key, {
397
+ answer: outcome.promise, kind, daemonConnectionId, expiresAt: timeoutMs === null ? null : Date.now() + timeoutMs,
398
+ resolve: response => { release(); outcome.resolve(response); },
399
+ reject: error => { release(); outcome.reject(error); }
400
+ });
401
+ if (signal?.aborted)
402
+ aborted();
403
+ else
404
+ signal?.addEventListener('abort', aborted, { once: true });
405
+ void this.armAlarm();
406
+ return outcome.promise;
407
+ }
408
+ trackStreamPending(key, daemonConnectionId) {
409
+ this.pending.set(key, {
410
+ daemonConnectionId,
411
+ resolve: () => undefined,
412
+ reject: () => undefined,
413
+ expiresAt: Date.now() + PENDING_ENTRY_TIMEOUT_MS,
414
+ kind: 'stream'
415
+ });
416
+ void this.armAlarm();
417
+ }
418
+ // -------------------------------------------------------------------------
419
+ // Wake claim (cloud fleet lifecycle)
420
+ // -------------------------------------------------------------------------
421
+ /**
422
+ * The per-host wake claim. A disconnected handshake asks here before any
423
+ * provider wake is dispatched: exactly one caller receives `dispatch: true`;
424
+ * concurrent or retry handshakes inside the claim window receive
425
+ * `dispatch: false` and must wait for an OBSERVED daemon socket instead of
426
+ * synthesizing readiness. A live socket answers `connected: true`.
427
+ */
428
+ async handleWakeClaim() {
429
+ if (this.ctx.getWebSockets('daemon').length > 0) {
430
+ return Response.json({ dispatch: false, connected: true });
431
+ }
432
+ const now = Date.now();
433
+ const persisted = await this.ctx.storage.get(WAKE_CLAIM_KEY);
434
+ const claimed = this.wakeClaimedAt;
435
+ if (claimed != null && now - claimed < WAKE_CLAIM_TTL_MS) {
436
+ return Response.json({ dispatch: false, connected: false });
437
+ }
438
+ if (persisted != null && now - persisted < WAKE_CLAIM_TTL_MS) {
439
+ this.wakeClaimedAt = persisted;
440
+ return Response.json({ dispatch: false, connected: false });
441
+ }
442
+ this.wakeClaimedAt = now;
443
+ await this.ctx.storage.put(WAKE_CLAIM_KEY, now);
444
+ return Response.json({ dispatch: true, connected: false });
445
+ }
446
+ /**
447
+ * A dispatch that produced no starting placement releases the claim, so the
448
+ * next handshake may retry immediately rather than waiting out the window.
449
+ */
450
+ async handleWakeResult(request) {
451
+ let raw;
452
+ try {
453
+ raw = JsonValueSchema.parse(await request.json());
454
+ }
455
+ catch (caught) {
456
+ this.report({ type: 'wake-result-invalid', error: CaughtFailureSchema.parse(caught) });
457
+ return new Response('invalid JSON', { status: 400 });
458
+ }
459
+ const body = z.object({ ok: z.unknown().optional() }).safeParse(raw);
460
+ if (!body.success || body.data.ok !== true) {
461
+ this.wakeClaimedAt = null;
462
+ await this.ctx.storage.delete(WAKE_CLAIM_KEY);
463
+ }
464
+ return new Response(null, { status: 204 });
465
+ }
466
+ // -------------------------------------------------------------------------
467
+ // Capability token management (A, B)
468
+ // Key: `token:<consumerId>` -> sessionToken
469
+ // -------------------------------------------------------------------------
470
+ tokenKey(consumerId) {
471
+ return `token:${consumerId}`;
472
+ }
473
+ async mintCapabilityToken(daemonConnectionId, caller) {
474
+ // Opportunistic reclaim: the keepalive alarm only fires when the daemon goes
475
+ // SILENT, so on a healthy long-lived daemon mint-time is the reliable trim
476
+ // point for expired consumer rows (mirrors the self-trimming reconnect window
477
+ // at recordReconnect). Without it, churning consumers leak permanent rows.
478
+ await this.sweepExpiredCapabilityTokens();
479
+ const consumerId = crypto.randomUUID();
480
+ const sessionToken = crypto.randomUUID();
481
+ const stored = {
482
+ sessionToken,
483
+ expiresAt: Date.now() + CAPABILITY_TOKEN_TTL_MS,
484
+ daemonConnectionId
485
+ };
486
+ if (caller) {
487
+ stored.userId = caller.userId;
488
+ if (caller.orgId)
489
+ stored.orgId = caller.orgId;
490
+ }
491
+ await this.ctx.storage.put(this.tokenKey(consumerId), stored);
492
+ return { consumerId, sessionToken };
493
+ }
494
+ /** Decode the owner record; malformed and obsolete rows cannot admit work. */
495
+ async readStoredToken(consumerId) {
496
+ const raw = await this.ctx.storage.get(this.tokenKey(consumerId));
497
+ const parsed = StoredCapabilityTokenSchema.safeParse(raw);
498
+ if (!parsed.success && raw !== undefined)
499
+ await this.ctx.storage.delete(this.tokenKey(consumerId));
500
+ return parsed.success ? parsed.data : null;
501
+ }
502
+ /**
503
+ * Validate (consumerId, sessionToken). FAIL-CLOSED: any missing/unknown/mismatch
504
+ * returns false. Constant-time comparison prevents timing attacks. An expired
505
+ * token is rejected AND pruned lazily (mirrors repo-coordinator.verifyToken).
506
+ */
507
+ async validateCapabilityToken(consumerId, sessionToken, allowClosing = false) {
508
+ if (!consumerId || !sessionToken || await this.isRetired())
509
+ return false;
510
+ const stored = await this.readStoredToken(consumerId);
511
+ if (!stored || (!allowClosing && stored.state && stored.state !== 'open'))
512
+ return false;
513
+ if (stored.expiresAt < Date.now()) {
514
+ await this.ctx.storage.delete(this.tokenKey(consumerId));
515
+ return false;
516
+ }
517
+ return !this.retired && constantTimeEqual(sessionToken, stored.sessionToken);
518
+ }
519
+ /**
520
+ * D1: a consumer pair acts only for the caller its handshake was authorized
521
+ * for. Another user holding the pair (with their own authority over this
522
+ * host) is refused, as is the same user admitted in a different
523
+ * organization; a pair minted before identity binding stays unbound.
524
+ */
525
+ async consumerUser(consumerId, request) {
526
+ const stored = await this.readStoredToken(consumerId);
527
+ if (!stored?.userId)
528
+ return { ok: true, caller: null };
529
+ const presented = verifiedCallerFromHeaders(request);
530
+ if (!presented || presented.userId !== stored.userId)
531
+ return { ok: false };
532
+ if (stored.orgId !== undefined && presented.orgId !== stored.orgId)
533
+ return { ok: false };
534
+ const caller = { userId: stored.userId };
535
+ if (stored.orgId)
536
+ caller.orgId = stored.orgId;
537
+ return { ok: true, caller };
538
+ }
539
+ async revokeCapabilityToken(consumerId) {
540
+ await this.ctx.storage.delete(this.tokenKey(consumerId));
541
+ }
542
+ /**
543
+ * Delete EVERY `token:<consumerId>` row for this host. A dropped daemon socket
544
+ * invalidates all consumer sessions (a consumer can't reach a daemon that's
545
+ * gone), so the capability keyspace is evicted wholesale on close rather than
546
+ * left to accumulate permanent rows nothing else reclaims.
547
+ */
548
+ async clearAllCapabilityTokens() {
549
+ const rows = await this.ctx.storage.list({ prefix: 'token:' });
550
+ for (const key of rows.keys()) {
551
+ await this.ctx.storage.delete(key);
552
+ }
553
+ }
554
+ /**
555
+ * Delete expired capability rows. Driven by the keepalive alarm and a mint-time
556
+ * opportunistic call so a long-lived daemon (where webSocketClose never fires)
557
+ * still self-trims. Legacy bare-string rows have no expiry and are left for the
558
+ * close-time clear.
559
+ */
560
+ async sweepExpiredCapabilityTokens() {
561
+ const now = Date.now();
562
+ const rows = await this.ctx.storage.list({ prefix: 'token:' });
563
+ for (const [key, raw] of rows) {
564
+ const stored = StoredCapabilityTokenSchema.safeParse(raw);
565
+ if (!stored.success || stored.data.expiresAt < now)
566
+ await this.ctx.storage.delete(key);
567
+ }
568
+ }
569
+ // ------------------------------------------------------------------
570
+ // Hibernation lifecycle callbacks
571
+ // ------------------------------------------------------------------
572
+ async webSocketMessage(ws, message) {
573
+ if (await this.isRetired())
574
+ return;
575
+ await this.persistKeepaliveDueAt(Date.now() + KEEPALIVE_ALARM_MS);
576
+ await this.armAlarm();
577
+ // ANY message is proof of life — including the echoed keepalive probe
578
+ // `alarm()` sends. Clear the pending-probe flag so the NEXT silent window
579
+ // gets a fresh probe-and-wait cycle rather than an immediate evict.
580
+ await this.ctx.storage.delete(KEEPALIVE_PROBE_PENDING_KEY);
581
+ if (this.retired)
582
+ return;
583
+ const textMessage = z.string().safeParse(message);
584
+ if (textMessage.success) {
585
+ let envelope;
586
+ try {
587
+ const parsed = RelayJsonMessageSchema.safeParse(JSON.parse(textMessage.data));
588
+ if (!parsed.success) {
589
+ this.report({ type: 'daemon-envelope-invalid' });
590
+ return;
591
+ }
592
+ envelope = parsed.data;
593
+ }
594
+ catch (caught) {
595
+ this.report({
596
+ type: 'daemon-envelope-malformed',
597
+ error: CaughtFailureSchema.parse(caught)
598
+ });
599
+ return;
600
+ }
601
+ if (envelope.type === 'keepalive') {
602
+ this.hasDaemonSocket = true;
603
+ return;
604
+ }
605
+ if (envelope.type === 'consumer_closed') {
606
+ const parsed = RelayConsumerClosedMessageSchema.safeParse(envelope);
607
+ if (!parsed.success)
608
+ return;
609
+ const key = 'close:' + pendingKey(parsed.data.consumerId, parsed.data.response.id);
610
+ const pending = this.pending.get(key);
611
+ if (pending?.kind !== 'close' || pending.daemonConnectionId !== readSocketMeta(ws)?.connectionId)
612
+ return;
613
+ this.pending.delete(key);
614
+ pending.resolve(parsed.data.response);
615
+ return;
616
+ }
617
+ if (envelope.type === 'rpc_response') {
618
+ // Route by consumerId (DO-issued, echoed by daemon). No extra token check:
619
+ // the daemon's consumerId came from the DO itself (we minted it).
620
+ const parsed = RelayRpcResponseMessageSchema.safeParse(envelope);
621
+ if (!parsed.success)
622
+ return;
623
+ const { consumerId, response } = parsed.data;
624
+ const key = pendingKey(consumerId, String(response.id));
625
+ const pending = this.pending.get(key);
626
+ if (pending && pending.kind !== 'close' && pending.daemonConnectionId === readSocketMeta(ws)?.connectionId) {
627
+ this.pending.delete(key);
628
+ pending.resolve(response);
629
+ }
630
+ return;
631
+ }
632
+ if (envelope.type === 'rpc_request') {
633
+ // G: REVERSE LEG - route ONLY to the owning consumer's reverse channel.
634
+ const parsed = RelayRpcRequestMessageSchema.safeParse(envelope);
635
+ if (!parsed.success)
636
+ return;
637
+ const consumerId = parsed.data.consumerId;
638
+ if (!consumerId)
639
+ return; // fail-closed
640
+ const reverse = this.reverseStreams.get(consumerId);
641
+ if (reverse && reverse.daemonConnectionId === readSocketMeta(ws)?.connectionId) {
642
+ try {
643
+ reverse.controller.enqueue(reverse.encoder.encode(`event: reverse_invoke\ndata: ${JSON.stringify(envelope)}\n\n`));
644
+ }
645
+ catch (caught) {
646
+ this.report({
647
+ type: 'stream-enqueue-failed',
648
+ error: CaughtFailureSchema.parse(caught)
649
+ });
650
+ this.reverseStreams.delete(consumerId);
651
+ }
652
+ }
653
+ return;
654
+ }
655
+ return;
656
+ }
657
+ // Binary frame: E - validate channel ownership before routing.
658
+ const binaryMessage = z.instanceof(ArrayBuffer).safeParse(message);
659
+ if (!binaryMessage.success)
660
+ return;
661
+ const bytes = new Uint8Array(binaryMessage.data);
662
+ if (bytes.length === 0) {
663
+ this.hasDaemonSocket = true;
664
+ return;
665
+ }
666
+ const { channelId, frameType, payload } = decodeBinaryFrame(bytes);
667
+ const stream = this.streams.get(channelId);
668
+ if (!stream || stream.daemonConnectionId !== readSocketMeta(ws)?.connectionId)
669
+ return;
670
+ try {
671
+ const frameBytes = frameType === FRAME_CLOSE
672
+ ? new Uint8Array([FRAME_CLOSE])
673
+ : (() => {
674
+ const out = new Uint8Array(1 + payload.length);
675
+ out[0] = FRAME_DATA;
676
+ out.set(payload, 1);
677
+ return out;
678
+ })();
679
+ stream.controller.enqueue(stream.encoder.encode(`event: ${SSE_FRAME_EVENT}\ndata: ${encodeBase64(frameBytes)}\n\n`));
680
+ if (frameType === FRAME_CLOSE) {
681
+ this.closeStream(stream);
682
+ this.streams.delete(channelId);
683
+ }
684
+ }
685
+ catch (caught) {
686
+ this.report({
687
+ type: 'stream-enqueue-failed',
688
+ error: CaughtFailureSchema.parse(caught)
689
+ });
690
+ this.streams.delete(channelId);
691
+ }
692
+ }
693
+ async webSocketClose(ws, code, reason, wasClean) {
694
+ // Compute this FIRST. A reconnect ACCEPTS the replacement socket BEFORE the
695
+ // old one's close event fires, so "the daemon dropped" is only true when no
696
+ // other daemon socket is live. The host-level side effects below (keepalive
697
+ // alarm, presence row, capability keyspace) belong to the HOST, not to this
698
+ // socket — running them for a replaced socket deleted the new socket's
699
+ // alarm and flipped relay_reachable to 0 under a live daemon.
700
+ const liveDaemons = this.ctx.getWebSockets('daemon').filter((s) => s !== ws);
701
+ const replaced = liveDaemons.length > 0;
702
+ const meta = readSocketMeta(ws);
703
+ const hostId = meta?.hostId ?? (await this.ctx.storage.get('hostId'));
704
+ this.report({
705
+ type: 'daemon-socket-closed',
706
+ hostId: hostId ?? null,
707
+ code,
708
+ reason,
709
+ wasClean,
710
+ ageMs: meta ? Date.now() - meta.acceptedAt : null,
711
+ replaced,
712
+ liveDaemons: liveDaemons.length,
713
+ pending: this.pending.size,
714
+ streams: this.streams.size,
715
+ reverseStreams: this.reverseStreams.size
716
+ });
717
+ const daemonConnectionId = readSocketMeta(ws)?.connectionId;
718
+ for (const [key, pending] of this.pending) {
719
+ if (pending.daemonConnectionId !== daemonConnectionId)
720
+ continue;
721
+ this.pending.delete(key);
722
+ pending.reject({ code: 'RPC_DISCONNECTED', message: 'owning daemon socket closed' });
723
+ }
724
+ for (const channels of [this.streams, this.reverseStreams]) {
725
+ for (const [key, stream] of channels) {
726
+ if (stream.daemonConnectionId !== daemonConnectionId)
727
+ continue;
728
+ this.closeStream(stream);
729
+ channels.delete(key);
730
+ }
731
+ }
732
+ if (replaced)
733
+ return;
734
+ // Per-HOST state: only when the last daemon socket is gone.
735
+ this.hasDaemonSocket = false;
736
+ this.nextAlarmAt = null;
737
+ await this.persistKeepaliveDueAt(null);
738
+ await this.ctx.storage.deleteAlarm();
739
+ // A dropped daemon socket invalidates every consumer session for this host,
740
+ // so evict the whole `token:` keyspace instead of leaking permanent rows.
741
+ await this.clearAllCapabilityTokens();
742
+ if (hostId)
743
+ await this.writePresence(hostId, false);
744
+ }
745
+ async webSocketError(ws, error) {
746
+ this.report({ type: 'daemon-socket-error', error: CaughtFailureSchema.parse(error) });
747
+ }
748
+ // ------------------------------------------------------------------
749
+ // Alarm handler
750
+ // ------------------------------------------------------------------
751
+ async alarm() {
752
+ if (await this.isRetired())
753
+ return;
754
+ const now = Date.now();
755
+ this.nextAlarmAt = null;
756
+ this.expireDuePendings(now);
757
+ // Reclaim expired capability rows on alarm — belt to the
758
+ // webSocketClose suspenders, and the reclaimer of last resort for a daemon
759
+ // that went silent (the alarm fires, sweeps, then closes the dead socket).
760
+ await this.sweepExpiredCapabilityTokens();
761
+ const sockets = this.openDaemonSockets();
762
+ const keepaliveAt = await this.resolvedKeepaliveDueAt(now, sockets);
763
+ const keepaliveDue = keepaliveAt != null && keepaliveAt <= now;
764
+ if (!keepaliveDue) {
765
+ await this.armAlarm();
766
+ return;
767
+ }
768
+ const hostId = (await this.ctx.storage.get('hostId')) ?? null;
769
+ if (sockets.length === 0) {
770
+ await this.ctx.storage.delete(KEEPALIVE_PROBE_PENDING_KEY);
771
+ await this.persistKeepaliveDueAt(null);
772
+ this.report({ type: 'keepalive-no-socket', hostId });
773
+ if (hostId)
774
+ await this.writePresence(hostId, false);
775
+ await this.armAlarm();
776
+ return;
777
+ }
778
+ // LIVENESS FROM THE EDGE. A daemon keepalive (today: its echo of the probe
779
+ // below; a client-initiated ping once framework #463 lands) is auto-answered
780
+ // without waking this DO, so it never reaches webSocketMessage — the
781
+ // runtime's per-socket auto-response timestamp is the primary proof of life.
782
+ const lastAnsweredAt = this.lastKeepaliveAnsweredAt(sockets);
783
+ const sinceAnsweredMs = lastAnsweredAt == null ? null : now - lastAnsweredAt;
784
+ if (sinceAnsweredMs != null && sinceAnsweredMs <= KEEPALIVE_LIVENESS_WINDOW_MS) {
785
+ await this.ctx.storage.delete(KEEPALIVE_PROBE_PENDING_KEY);
786
+ await this.persistKeepaliveDueAt(now + KEEPALIVE_ALARM_MS);
787
+ await this.armAlarm();
788
+ this.report({ type: 'keepalive-alive', hostId, sockets: sockets.length, sinceAnsweredMs });
789
+ return;
790
+ }
791
+ // PROBE BEFORE EVICT (§3.7 keepalive frame). The daemon's RelayListenSession
792
+ // already echoes `{type:'keepalive'}` the instant it receives one
793
+ // (runtime-rpc/src/relay/relay-listen-session.ts) — but nothing on this side
794
+ // ever SENT one, so a daemon idle for KEEPALIVE_ALARM_MS with no organic RPC
795
+ // traffic (the common case for a host with nothing queued) was evicted on
796
+ // silence alone, even though it was alive and would have answered. That
797
+ // forced a needless reconnect (and the daemon's connect loop always pays a
798
+ // ~1s backoff before redialing), repeating every idle window — the "relay
799
+ // socket closed" / "reconnecting" churn seen in fleet-daemon logs.
800
+ //
801
+ // Fix: on the FIRST silent window, send the probe and give the daemon one
802
+ // more full window to answer (webSocketMessage resets the alarm AND clears
803
+ // the pending flag on ANY reply, including this echo). Only a daemon that
804
+ // stays silent through a SECOND window in a row — i.e. doesn't even answer
805
+ // a direct ping — is actually gone.
806
+ const probePending = await this.ctx.storage.get(KEEPALIVE_PROBE_PENDING_KEY);
807
+ if (!probePending) {
808
+ await this.ctx.storage.put(KEEPALIVE_PROBE_PENDING_KEY, true);
809
+ let sent = 0;
810
+ for (const ws of sockets) {
811
+ if (this.sendToSocket(ws, KEEPALIVE_AUTO_RESPONSE_REQUEST))
812
+ sent++;
813
+ }
814
+ await this.persistKeepaliveDueAt(now + KEEPALIVE_ALARM_MS);
815
+ await this.armAlarm();
816
+ this.report({ type: 'keepalive-probe-sent', hostId, sockets: sockets.length, sent, sinceAnsweredMs });
817
+ return;
818
+ }
819
+ await this.ctx.storage.delete(KEEPALIVE_PROBE_PENDING_KEY);
820
+ // Second silent window with a probe outstanding. The daemon's echo does NOT
821
+ // wake us (it is auto-answered), so consult the edge once more: an answer
822
+ // inside two windows means the socket is alive and the probe merely landed
823
+ // late — keep it. Only a socket with nothing answered beyond that is gone.
824
+ if (sinceAnsweredMs != null && sinceAnsweredMs <= KEEPALIVE_EVICT_STALE_MS) {
825
+ await this.persistKeepaliveDueAt(now + KEEPALIVE_ALARM_MS);
826
+ await this.armAlarm();
827
+ this.report({ type: 'keepalive-probe-answered', hostId, sockets: sockets.length, sinceAnsweredMs });
828
+ return;
829
+ }
830
+ this.report({ type: 'keepalive-evicting', hostId, sockets: sockets.length, sinceAnsweredMs, code: 4001 });
831
+ await this.persistKeepaliveDueAt(null);
832
+ for (const ws of sockets) {
833
+ this.closeSocket(ws, 4001, 'keepalive timeout');
834
+ }
835
+ if (hostId)
836
+ await this.writePresence(hostId, false);
837
+ await this.armAlarm();
838
+ }
839
+ // ------------------------------------------------------------------
840
+ // Internal fetch dispatch
841
+ // ------------------------------------------------------------------
842
+ async fetch(request) {
843
+ const url = new URL(request.url);
844
+ const action = url.pathname;
845
+ if (action === '/do/retire' && request.method === 'POST') {
846
+ const hostId = request.headers.get('x-relay-host-id');
847
+ if (!hostId)
848
+ return new Response('Missing host identity', { status: 400 });
849
+ return this.retire(hostId);
850
+ }
851
+ if (await this.isRetired())
852
+ return new Response('Host retired', { status: 410 });
853
+ if (action === '/do/relay-state' && request.method === 'GET') {
854
+ return Response.json({ connected: this.ctx.getWebSockets('daemon').length > 0 });
855
+ }
856
+ if (action === '/do/wake/claim' && request.method === 'POST') {
857
+ return this.handleWakeClaim();
858
+ }
859
+ if (action === '/do/wake/result' && request.method === 'POST') {
860
+ return this.handleWakeResult(request);
861
+ }
862
+ if (action === '/do/accept-daemon' && request.method === 'GET') {
863
+ return this.handleAcceptDaemon(request);
864
+ }
865
+ if (action === '/do/rpc' && request.method === 'POST') {
866
+ return this.handleRpc(request);
867
+ }
868
+ if (action === '/do/stream' && request.method === 'GET') {
869
+ return this.handleStream(url, request);
870
+ }
871
+ if (action === '/do/stream/write' && request.method === 'POST') {
872
+ return this.handleStreamWrite(request);
873
+ }
874
+ if (action === '/do/stream/close' && request.method === 'POST') {
875
+ return this.handleStreamClose(request);
876
+ }
877
+ return new Response('not found', { status: 404 });
878
+ }
879
+ // ------------------------------------------------------------------
880
+ // Accept daemon WS upgrade
881
+ // ------------------------------------------------------------------
882
+ async handleAcceptDaemon(request) {
883
+ const upgradeHeader = request.headers.get('Upgrade');
884
+ if (!upgradeHeader || upgradeHeader.toLowerCase() !== 'websocket') {
885
+ return new Response('Expected WebSocket upgrade', { status: 426 });
886
+ }
887
+ const hostId = request.headers.get('x-relay-host-id');
888
+ if (!hostId)
889
+ return new Response('Missing x-relay-host-id', { status: 400 });
890
+ // H: rate-limit reconnects (persisted across hibernation) — ENFORCED, not
891
+ // observational. A storm is rejected BEFORE displacing the live socket, so
892
+ // it cannot perpetually evict an authenticated daemon (DoS protection).
893
+ if (!(await this.recordReconnect())) {
894
+ return new Response('reconnect rate exceeded', { status: 429 });
895
+ }
896
+ // The outer request may have validated before retirement. Re-read at the
897
+ // accepting owner and fence an admission delayed behind a retirement call.
898
+ const admission = await this.ports.hostAdmission(hostId);
899
+ if (admission === 'missing')
900
+ return new Response('Host not found', { status: 404 });
901
+ if (admission === 'retired') {
902
+ await this.retire(hostId);
903
+ return new Response('Host retired', { status: 410 });
904
+ }
905
+ if (await this.isRetired())
906
+ return new Response('Host retired', { status: 410 });
907
+ const existing = this.ctx.getWebSockets('daemon');
908
+ for (const ws of existing) {
909
+ this.closeSocket(ws, 4000, 'replaced by new connection');
910
+ }
911
+ const { client, server } = this.ports.createSocketPair();
912
+ this.ctx.acceptWebSocket(server, ['daemon']);
913
+ const caps = (request.headers.get(RELAY_DAEMON_CAPS_HEADER) ?? '').split(',').filter((cap) => cap.length > 0);
914
+ try {
915
+ attachSocketMeta(server, { hostId, acceptedAt: Date.now(), connectionId: crypto.randomUUID(), caps });
916
+ }
917
+ catch (caught) {
918
+ this.closeSocket(server, 1011, 'daemon identity unavailable');
919
+ throw caught;
920
+ }
921
+ this.hasDaemonSocket = true;
922
+ // A live socket discharges the wake claim: a later disconnect is a fresh
923
+ // wake, not a coalesced retry of the one that just booted.
924
+ this.wakeClaimedAt = null;
925
+ void this.ctx.storage.delete(WAKE_CLAIM_KEY);
926
+ this.report({ type: 'daemon-accepted', hostId, replaced: existing.length });
927
+ void this.ctx.storage.put('hostId', hostId);
928
+ this.keepaliveDueAt = Date.now() + KEEPALIVE_ALARM_MS;
929
+ void this.ctx.storage.put(KEEPALIVE_DUE_AT_KEY, this.keepaliveDueAt);
930
+ void this.armAlarm();
931
+ // A fresh socket starts with no probe outstanding, even if the replaced
932
+ // connection had one in flight.
933
+ void this.ctx.storage.delete(KEEPALIVE_PROBE_PENDING_KEY);
934
+ void this.writePresence(hostId, true);
935
+ // The 101 upgrade init is a workerd extension of ResponseInit; the host's
936
+ // Response constructor reads `webSocket` from it at the edge.
937
+ const upgrade = { status: 101, webSocket: client };
938
+ // SAFETY: the pair's client socket is the host's own WebSocket behind the
939
+ // structural RelayBrokerSocket the port handed back.
940
+ return new Response(null, upgrade);
941
+ }
942
+ /**
943
+ * H: persist reconnect timestamps across hibernation AND enforce the cap.
944
+ * Returns false when the per-minute limit is exceeded so the caller rejects
945
+ * the connection rather than displacing the live socket. The over-limit
946
+ * attempt still counts toward the window (a sustained storm stays blocked).
947
+ */
948
+ async recordReconnect() {
949
+ const now = Date.now();
950
+ const windowStart = now - 60_000;
951
+ const timestamps = (await this.ctx.storage.get('reconnect_timestamps')) ?? [];
952
+ const recent = timestamps.filter((t) => t > windowStart);
953
+ recent.push(now);
954
+ await this.ctx.storage.put('reconnect_timestamps', recent);
955
+ if (recent.length > MAX_RECONNECT_PER_MINUTE) {
956
+ this.report({ type: 'reconnect-rate-exceeded', recentPerMinute: recent.length });
957
+ return false;
958
+ }
959
+ return true;
960
+ }
961
+ // ------------------------------------------------------------------
962
+ // Handle RPC: handshake mints tokens; all others validate FAIL-CLOSED.
963
+ // ------------------------------------------------------------------
964
+ async handleConsumerClose(input, request) {
965
+ const { consumerId, sessionToken, id } = input;
966
+ if (!(await this.validateCapabilityToken(consumerId, sessionToken, true)))
967
+ return tokenError();
968
+ const stored = await this.readStoredToken(consumerId);
969
+ if (!stored)
970
+ return tokenError();
971
+ if (stored.state === 'closed')
972
+ return Response.json({ id });
973
+ const socket = this.socketForOwner(stored.daemonConnectionId);
974
+ if (!socket)
975
+ return Response.json({ id, error: { code: 'RPC_DISCONNECTED', message: 'no daemon socket' } }, { status: 503 });
976
+ // Revoke normal calls before forwarding, retaining only authenticated close
977
+ // retry authority until the existing token expiry.
978
+ await this.ctx.storage.put(this.tokenKey(consumerId), { ...stored, state: 'closing' });
979
+ const key = 'close:' + pendingKey(consumerId, id);
980
+ const answer = this.waitForPending(key, { timeoutMs: PENDING_ENTRY_TIMEOUT_MS, kind: 'close', signal: request.signal, daemonConnectionId: stored.daemonConnectionId });
981
+ try {
982
+ socket.send(JSON.stringify({ type: 'consumer_close', consumerId, id }));
983
+ const response = await answer;
984
+ if (!response.error) {
985
+ await this.ctx.storage.put(this.tokenKey(consumerId), { ...stored, state: 'closed' });
986
+ const reverse = this.reverseStreams.get(consumerId);
987
+ if (reverse) {
988
+ this.closeStream(reverse);
989
+ this.reverseStreams.delete(consumerId);
990
+ }
991
+ }
992
+ return Response.json(response);
993
+ }
994
+ catch (caught) {
995
+ this.report({ type: 'consumer-close-failed', error: CaughtFailureSchema.parse(caught) });
996
+ const pending = this.pending.get(key);
997
+ this.pending.delete(key);
998
+ pending?.resolve({ id, error: { code: 'RPC_DISCONNECTED', message: 'consumer cleanup was not acknowledged' } });
999
+ return Response.json({ id, error: { code: 'RPC_DISCONNECTED', message: 'consumer cleanup was not acknowledged' } }, { status: 503 });
1000
+ }
1001
+ }
1002
+ async handleRpc(request) {
1003
+ let body;
1004
+ try {
1005
+ // SAFETY: every branch below re-validates the fields it uses (close
1006
+ // input schema, capability token, handshake result) before acting.
1007
+ body = (await request.json());
1008
+ }
1009
+ catch (caught) {
1010
+ this.report({ type: 'rpc-body-invalid', error: CaughtFailureSchema.parse(caught) });
1011
+ return new Response('invalid JSON', { status: 400 });
1012
+ }
1013
+ const close = ConsumerCloseInput.safeParse(body);
1014
+ if (close.success)
1015
+ return this.handleConsumerClose(close.data, request);
1016
+ if (z.object({ type: z.literal('consumer_close') }).safeParse(body).success)
1017
+ return new Response('invalid close control', { status: 400 });
1018
+ // G: Reverse reply - validate capability token before forwarding down daemon socket.
1019
+ const reverseReply = z
1020
+ .looseObject({
1021
+ type: z.literal('rpc_response'),
1022
+ consumerId: z.string(),
1023
+ response: RelayRpcResponseMessageSchema.shape.response,
1024
+ sessionToken: z.string().optional()
1025
+ })
1026
+ .safeParse(body);
1027
+ if (reverseReply.success) {
1028
+ const rev = reverseReply.data;
1029
+ if (!(await this.validateCapabilityToken(rev.consumerId, rev.sessionToken)) || this.retired) {
1030
+ return tokenError();
1031
+ }
1032
+ const socket = await this.socketForConsumer(rev.consumerId);
1033
+ if (socket) {
1034
+ // Strip sessionToken - internal credential, never forwarded to daemon.
1035
+ const forward = { type: 'rpc_response', consumerId: rev.consumerId, response: rev.response };
1036
+ this.sendToSocket(socket, JSON.stringify(forward));
1037
+ }
1038
+ return new Response(null, { status: 202 });
1039
+ }
1040
+ // SAFETY: not a reverse reply, so the body is the consumer's RPC request;
1041
+ // capability validation and the daemon's invoke pipeline re-validate it.
1042
+ const rpcBody = body;
1043
+ // A: __relay_handshake - the ONLY call that does NOT require a pre-existing token.
1044
+ // It mints the capability pair and returns it to the consumer.
1045
+ const handshakeFields = z
1046
+ .looseObject({ method: z.string().optional(), id: z.string().optional() })
1047
+ .safeParse(rpcBody);
1048
+ if (rpcBody.verb === 'invoke' &&
1049
+ handshakeFields.success &&
1050
+ handshakeFields.data.method === '__relay_handshake') {
1051
+ const socket = this.openDaemonSockets()[0];
1052
+ if (!socket || !readSocketMeta(socket)) {
1053
+ return Response.json({ error: { code: 'RPC_DISCONNECTED', message: 'no daemon socket' } }, { status: 503 });
1054
+ }
1055
+ const daemonMeta = readSocketMeta(socket);
1056
+ const daemonConnectionId = daemonMeta.connectionId;
1057
+ const verifiedCaller = verifiedCallerFromHeaders(request);
1058
+ const capability = await this.mintCapabilityToken(daemonConnectionId, verifiedCaller);
1059
+ if (this.retired) {
1060
+ await this.revokeCapabilityToken(capability.consumerId);
1061
+ return new Response('Host retired', { status: 410 });
1062
+ }
1063
+ const requestId = handshakeFields.data.id ?? crypto.randomUUID();
1064
+ // Compound key (consumerId+requestId) — the response router matches on
1065
+ // consumerId + response.id, and the daemon echoes id === requestId.
1066
+ const handshakePendingKey = pendingKey(capability.consumerId, requestId);
1067
+ const responsePromise = this.waitForPending(handshakePendingKey, { timeoutMs: PENDING_ENTRY_TIMEOUT_MS, signal: request.signal, daemonConnectionId });
1068
+ const envelope = stampCaller({
1069
+ type: 'rpc_request',
1070
+ consumerId: capability.consumerId,
1071
+ request: { ...rpcBody, id: requestId }
1072
+ }, daemonMeta, verifiedCaller);
1073
+ try {
1074
+ socket.send(JSON.stringify(envelope));
1075
+ }
1076
+ catch (caught) {
1077
+ this.report({ type: 'socket-send-failed', error: CaughtFailureSchema.parse(caught) });
1078
+ this.pending.delete(handshakePendingKey);
1079
+ await this.revokeCapabilityToken(capability.consumerId);
1080
+ return Response.json({ error: { code: 'RPC_DISCONNECTED', message: 'daemon socket send failed' } }, { status: 503 });
1081
+ }
1082
+ try {
1083
+ const response = await responsePromise;
1084
+ if (this.retired)
1085
+ return new Response('Host retired', { status: 410 });
1086
+ // A: normalize the handshake response for the relay client.
1087
+ // The daemon's RelayListenSession returns result.v = { rootRef, consumerId }.
1088
+ // connectRelay expects result.v = rootRef (string) and top-level consumerId+sessionToken.
1089
+ const parsedRoot = RelayDaemonHandshakeResultSchema.safeParse(response.result?.t === 'json' ? response.result.v : undefined);
1090
+ if (!parsedRoot.success || parsedRoot.data.consumerId !== capability.consumerId) {
1091
+ await this.revokeCapabilityToken(capability.consumerId);
1092
+ return Response.json({ error: { code: 'RPC_DISCONNECTED', message: 'invalid daemon handshake' } }, { status: 503 });
1093
+ }
1094
+ const rootRef = parsedRoot.data.rootRef;
1095
+ // Return normalized response: result.v = rootRef string, plus capability pair.
1096
+ const normalizedResponse = {
1097
+ ...response,
1098
+ result: { t: 'json', v: rootRef }
1099
+ };
1100
+ return Response.json({
1101
+ ...normalizedResponse,
1102
+ consumerId: capability.consumerId,
1103
+ sessionToken: capability.sessionToken
1104
+ });
1105
+ }
1106
+ catch (caught) {
1107
+ this.report({ type: 'handshake-failed', error: CaughtFailureSchema.parse(caught) });
1108
+ await this.revokeCapabilityToken(capability.consumerId);
1109
+ // Pending rejections are RelayPendingRejection records; anything else
1110
+ // is described without its value.
1111
+ const rejection = z
1112
+ .object({ code: z.string(), message: z.string() })
1113
+ .safeParse(caught);
1114
+ return Response.json({
1115
+ error: rejection.success
1116
+ ? rejection.data
1117
+ : { code: 'RPC_DISCONNECTED', message: 'handshake failed' }
1118
+ }, { status: 503 });
1119
+ }
1120
+ }
1121
+ // B: All other RPC calls require a valid capability token. FAIL-CLOSED.
1122
+ const { consumerId, sessionToken } = rpcBody;
1123
+ if (!(await this.validateCapabilityToken(consumerId, sessionToken)) || this.retired) {
1124
+ return tokenError();
1125
+ }
1126
+ const consumerUser = await this.consumerUser(consumerId, request);
1127
+ if (!consumerUser.ok)
1128
+ return consumerUserMismatch();
1129
+ const socket = await this.socketForConsumer(consumerId);
1130
+ if (!socket) {
1131
+ return Response.json({ error: { code: 'RPC_DISCONNECTED', message: 'no daemon socket' } }, { status: 503 });
1132
+ }
1133
+ const rpcPendingKey = pendingKey(consumerId, String(rpcBody.id));
1134
+ if (this.pending.has(rpcPendingKey))
1135
+ return Response.json({ id: rpcBody.id,
1136
+ error: { code: 'RPC_INVOKE_FAILED', message: 'request id already in use' } }, { status: 409 });
1137
+ const responsePromise = this.waitForPending(rpcPendingKey, { timeoutMs: null, signal: request.signal, daemonConnectionId: readSocketMeta(socket).connectionId });
1138
+ try {
1139
+ // Strip internal credentials before forwarding to daemon.
1140
+ const { consumerId: _cid, sessionToken: _st, ...forwardRequest } = rpcBody;
1141
+ const envelope = stampCaller({
1142
+ type: 'rpc_request',
1143
+ consumerId: consumerId,
1144
+ // SAFETY: rpcBody minus its credential fields is the consumer's own
1145
+ // RpcRequest; the daemon's invoke pipeline re-validates it.
1146
+ request: forwardRequest
1147
+ }, readSocketMeta(socket), consumerUser.caller);
1148
+ socket.send(JSON.stringify(envelope));
1149
+ }
1150
+ catch (caught) {
1151
+ this.report({ type: 'socket-send-failed', error: CaughtFailureSchema.parse(caught) });
1152
+ this.pending.delete(rpcPendingKey);
1153
+ return Response.json({ error: { code: 'RPC_DISCONNECTED', message: 'daemon socket send failed' } }, { status: 503 });
1154
+ }
1155
+ try {
1156
+ const response = await responsePromise;
1157
+ return Response.json(response);
1158
+ }
1159
+ catch (caught) {
1160
+ this.report({ type: 'request-failed', error: CaughtFailureSchema.parse(caught) });
1161
+ const rejection = z
1162
+ .object({ code: z.string(), message: z.string() })
1163
+ .safeParse(caught);
1164
+ return Response.json({
1165
+ error: rejection.success
1166
+ ? rejection.data
1167
+ : { code: 'RPC_DISCONNECTED', message: 'request failed' }
1168
+ }, { status: 503 });
1169
+ }
1170
+ }
1171
+ // ------------------------------------------------------------------
1172
+ // Stream open: DO-mints channelId (D); capability validation (B, G).
1173
+ // ------------------------------------------------------------------
1174
+ async handleStream(url, request) {
1175
+ const isReverse = url.searchParams.get('reverse') === '1';
1176
+ const consumerId = url.searchParams.get('consumerId') ?? undefined;
1177
+ const sessionToken = url.searchParams.get('sessionToken') ?? undefined;
1178
+ // B: validate capability token FAIL-CLOSED.
1179
+ if (!(await this.validateCapabilityToken(consumerId, sessionToken)) || this.retired) {
1180
+ return tokenError();
1181
+ }
1182
+ const consumerUser = await this.consumerUser(consumerId, request);
1183
+ if (!consumerUser.ok)
1184
+ return consumerUserMismatch();
1185
+ const streamCaller = consumerUser.caller;
1186
+ const socket = await this.socketForConsumer(consumerId);
1187
+ const daemonConnectionId = socket && readSocketMeta(socket)?.connectionId;
1188
+ if (!socket || !daemonConnectionId)
1189
+ return Response.json({ error: { code: 'RPC_DISCONNECTED', message: 'owning daemon is unavailable' } }, { status: 503 });
1190
+ // G: per-consumer reverse stream channel.
1191
+ if (isReverse) {
1192
+ if (this.reverseStreams.size >= MAX_REVERSE_STREAMS) {
1193
+ return Response.json({ error: { code: 'RPC_LIMIT', message: 'reverse stream limit reached' } }, { status: 429 });
1194
+ }
1195
+ const encoder = new TextEncoder();
1196
+ let controller;
1197
+ const stream = new ReadableStream({
1198
+ start: (ctrl) => {
1199
+ controller = ctrl;
1200
+ ctrl.enqueue(encoder.encode(': open\n\n'));
1201
+ // G: per-consumer slot; never evicts another consumer.
1202
+ this.reverseStreams.set(consumerId, { controller, encoder, consumerId: consumerId, daemonConnectionId });
1203
+ },
1204
+ cancel: () => {
1205
+ this.reverseStreams.delete(consumerId);
1206
+ }
1207
+ });
1208
+ return new Response(stream, {
1209
+ status: 200,
1210
+ headers: {
1211
+ 'content-type': 'text/event-stream',
1212
+ 'cache-control': 'no-cache',
1213
+ connection: 'keep-alive'
1214
+ }
1215
+ });
1216
+ }
1217
+ // Bound forward channels per host (mirrors the reverse-stream cap above).
1218
+ if (this.streams.size >= MAX_FORWARD_STREAMS) {
1219
+ return Response.json({ error: { code: 'RPC_LIMIT', message: 'forward stream limit reached' } }, { status: 429 });
1220
+ }
1221
+ // D: DO-minted channelId (not client-supplied).
1222
+ if (!socket) {
1223
+ return Response.json({ error: { code: 'RPC_DISCONNECTED', message: 'no daemon socket' } }, { status: 503 });
1224
+ }
1225
+ const channelId = crypto.randomUUID(); // D: DO-minted
1226
+ const encoder = new TextEncoder();
1227
+ let controller;
1228
+ const stream = new ReadableStream({
1229
+ start: (ctrl) => {
1230
+ controller = ctrl;
1231
+ ctrl.enqueue(encoder.encode(': open\n\n'));
1232
+ // D: store channelId -> consumerId ownership.
1233
+ this.streams.set(channelId, { controller, encoder, consumerId: consumerId, daemonConnectionId });
1234
+ const ref = url.searchParams.get('ref');
1235
+ const channel = url.searchParams.get('channel');
1236
+ const streamReq = RpcStreamSchema.parse({ verb: 'stream', id: channelId, ref, channel });
1237
+ const envelope = stampCaller({
1238
+ type: 'rpc_request',
1239
+ consumerId: consumerId,
1240
+ request: streamReq
1241
+ }, readSocketMeta(socket), streamCaller);
1242
+ // H: pending entry with alarm-driven timeout cleanup. Keyed by
1243
+ // consumerId+channelId (the stream-open's id IS the channelId) so
1244
+ // concurrent streams/RPCs from the same consumer do not collide.
1245
+ const streamPendingKey = pendingKey(consumerId, channelId);
1246
+ this.trackStreamPending(streamPendingKey, daemonConnectionId);
1247
+ try {
1248
+ socket.send(JSON.stringify(envelope));
1249
+ }
1250
+ catch (caught) {
1251
+ this.report({
1252
+ type: 'stream-open-send-failed',
1253
+ error: CaughtFailureSchema.parse(caught)
1254
+ });
1255
+ this.pending.delete(streamPendingKey);
1256
+ }
1257
+ },
1258
+ cancel: () => {
1259
+ this.streams.delete(channelId);
1260
+ // H: a consumer that opens then immediately drops the SSE connection
1261
+ // must not leak its pending entry. MAX_FORWARD_STREAMS frees the
1262
+ // stream slot on cancel, so it does NOT rate-limit open/cancel
1263
+ // cycling — without this cleanup each cycle orphans a pending row,
1264
+ // growing the per-host DO heap unbounded.
1265
+ // streamPendingKey is recomputable from the same (consumerId,
1266
+ // channelId) used at open.
1267
+ this.pending.delete(pendingKey(consumerId, channelId));
1268
+ }
1269
+ });
1270
+ // D: return DO-minted channelId in header so the relay client uses it.
1271
+ return new Response(stream, {
1272
+ status: 200,
1273
+ headers: {
1274
+ 'content-type': 'text/event-stream',
1275
+ 'cache-control': 'no-cache',
1276
+ connection: 'keep-alive',
1277
+ 'x-relay-channel-id': channelId
1278
+ }
1279
+ });
1280
+ }
1281
+ // ------------------------------------------------------------------
1282
+ // Stream write: validate capability + channel ownership (B, D).
1283
+ // ------------------------------------------------------------------
1284
+ async handleStreamWrite(request) {
1285
+ const parsedBody = StreamWriteBodySchema.safeParse(await request.json());
1286
+ const { channelId, consumerId, sessionToken, data } = parsedBody.success
1287
+ ? parsedBody.data
1288
+ : { channelId: undefined, consumerId: undefined, sessionToken: undefined, data: undefined };
1289
+ // B: validate capability token FAIL-CLOSED.
1290
+ if (!(await this.validateCapabilityToken(consumerId, sessionToken)) || this.retired) {
1291
+ return tokenError();
1292
+ }
1293
+ // D1: a bound pair writes only for its authorized caller.
1294
+ if (consumerId && !(await this.consumerUser(consumerId, request)).ok)
1295
+ return consumerUserMismatch();
1296
+ // D: validate channelId + consumerId present.
1297
+ if (!channelId || !consumerId) {
1298
+ return new Response(JSON.stringify({ error: { code: 'RPC_UNAUTHENTICATED', message: 'missing channelId or consumerId' } }), { status: 400, headers: { 'content-type': 'application/json' } });
1299
+ }
1300
+ const ch = this.streams.get(channelId);
1301
+ // Unknown channel: best-effort 202.
1302
+ if (!ch)
1303
+ return new Response(null, { status: 202 });
1304
+ // D: ownership check FAIL-CLOSED.
1305
+ if (ch.consumerId !== consumerId) {
1306
+ return new Response(JSON.stringify({ error: { code: 'RPC_FORBIDDEN', message: 'channel not owned by this consumer' } }), { status: 403, headers: { 'content-type': 'application/json' } });
1307
+ }
1308
+ const socket = await this.socketForConsumer(consumerId);
1309
+ if (socket && data !== undefined) {
1310
+ this.sendToSocket(socket, encodeBinaryFrame(channelId, FRAME_DATA, decodeBase64(data)));
1311
+ }
1312
+ return new Response(null, { status: 202 });
1313
+ }
1314
+ // ------------------------------------------------------------------
1315
+ // Stream close: validate capability + channel ownership (B, D).
1316
+ // ------------------------------------------------------------------
1317
+ async handleStreamClose(request) {
1318
+ const parsedBody = StreamCloseBodySchema.safeParse(await request.json());
1319
+ const { channelId, consumerId, sessionToken } = parsedBody.success
1320
+ ? parsedBody.data
1321
+ : { channelId: undefined, consumerId: undefined, sessionToken: undefined };
1322
+ // B: validate capability token FAIL-CLOSED.
1323
+ if (!(await this.validateCapabilityToken(consumerId, sessionToken)) || this.retired) {
1324
+ return tokenError();
1325
+ }
1326
+ // D1: a bound pair closes only for its authorized caller.
1327
+ if (consumerId && !(await this.consumerUser(consumerId, request)).ok)
1328
+ return consumerUserMismatch();
1329
+ // D: validate channelId + consumerId present.
1330
+ if (!channelId || !consumerId) {
1331
+ return new Response(JSON.stringify({ error: { code: 'RPC_UNAUTHENTICATED', message: 'missing channelId or consumerId' } }), { status: 400, headers: { 'content-type': 'application/json' } });
1332
+ }
1333
+ const ch = this.streams.get(channelId);
1334
+ if (ch && ch.consumerId !== consumerId) {
1335
+ // D: ownership check FAIL-CLOSED.
1336
+ return new Response(JSON.stringify({ error: { code: 'RPC_FORBIDDEN', message: 'channel not owned by this consumer' } }), { status: 403, headers: { 'content-type': 'application/json' } });
1337
+ }
1338
+ if (ch) {
1339
+ this.closeStream(ch);
1340
+ this.streams.delete(channelId);
1341
+ }
1342
+ const socket = await this.socketForConsumer(consumerId);
1343
+ if (socket) {
1344
+ this.sendToSocket(socket, encodeBinaryFrame(channelId, FRAME_CLOSE));
1345
+ }
1346
+ return new Response(null, { status: 202 });
1347
+ }
1348
+ // ------------------------------------------------------------------
1349
+ // Presence write
1350
+ // ------------------------------------------------------------------
1351
+ async writePresence(hostId, reachable) {
1352
+ try {
1353
+ await this.ports.onPresence?.(hostId, reachable);
1354
+ }
1355
+ catch (caught) {
1356
+ this.report({ type: 'presence-write-failed', error: CaughtFailureSchema.parse(caught) });
1357
+ }
1358
+ }
1359
+ }
1360
+ // ---------------------------------------------------------------------------
1361
+ // Helpers
1362
+ // ---------------------------------------------------------------------------
1363
+ /** Hibernation-persistent daemon identity: capabilities and acknowledgements bind to this socket generation. */
1364
+ const DaemonSocketMetaSchema = z.object({
1365
+ hostId: z.string().min(1), acceptedAt: z.number().finite(), connectionId: z.uuid(),
1366
+ /** What the daemon declared it reads (`caps`); an older attachment declared nothing. */
1367
+ caps: z.array(z.string()).default([])
1368
+ });
1369
+ function attachSocketMeta(ws, meta) {
1370
+ const socket = ws;
1371
+ if (!socket.serializeAttachment)
1372
+ throw new CapabilityError('Daemon socket has no durable identity attachment.', 'UNAVAILABLE', {
1373
+ reason: 'rpc-daemon-identity-unavailable'
1374
+ });
1375
+ socket.serializeAttachment(meta);
1376
+ }
1377
+ function readSocketMeta(ws) {
1378
+ const socket = ws;
1379
+ if (!socket.deserializeAttachment)
1380
+ return null;
1381
+ const parsed = DaemonSocketMetaSchema.safeParse(socket.deserializeAttachment());
1382
+ return parsed.success ? parsed.data : null;
1383
+ }
1384
+ /**
1385
+ * A forwarded request frame with the consumer's verified user as `caller`, for a
1386
+ * daemon that declared it reads one. An older daemon parses frames strictly and
1387
+ * would refuse the field, so it gets the frame unstamped.
1388
+ */
1389
+ function stampCaller(envelope, daemon, caller) {
1390
+ if (!caller || !daemon || !daemonReadsVerifiedCaller(daemon.caps))
1391
+ return envelope;
1392
+ const stamped = caller.orgId
1393
+ ? { userId: caller.userId, orgId: caller.orgId }
1394
+ : { userId: caller.userId };
1395
+ return { ...envelope, caller: stamped };
1396
+ }
1397
+ /** A consumer pair presented by a user other than the one it was minted for. */
1398
+ function consumerUserMismatch() {
1399
+ return Response.json({ error: { code: 'RPC_UNAUTHENTICATED', message: 'this relay consumer belongs to another user' } }, { status: 403 });
1400
+ }
1401
+ /** Standard capability-token rejection response. */
1402
+ function tokenError() {
1403
+ return new Response(JSON.stringify({
1404
+ error: { code: 'RPC_UNAUTHENTICATED', message: 'invalid or missing capability token' }
1405
+ }), { status: 401, headers: { 'content-type': 'application/json' } });
1406
+ }
1407
+ /**
1408
+ * Pending entries are keyed by consumerId + the request/channel id, NOT by
1409
+ * consumerId alone, so two concurrent in-flight calls from the SAME consumer
1410
+ * do not overwrite each other's resolver. The daemon echoes the id
1411
+ * (response.id === the request id; a stream-open uses the channelId as its id),
1412
+ * so the broker re-derives the identical key when routing the response.
1413
+ */
1414
+ function pendingKey(consumerId, id) {
1415
+ return `${consumerId}:${id}`;
1416
+ }
1417
+ //# sourceMappingURL=relay-broker.js.map