@vibesdotdev/kernel-rpc 1.0.0-rc.4 → 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.
- package/dist/cli/daemon/runtime.daemon.serve.impl.consumer.d.ts +2 -2
- package/dist/cli/daemon/runtime.daemon.serve.impl.consumer.d.ts.map +1 -1
- package/dist/cli/daemon/runtime.daemon.serve.impl.consumer.js +6 -0
- package/dist/cli/daemon/runtime.daemon.serve.impl.consumer.js.map +1 -1
- package/dist/core/helper.d.ts +1 -1
- package/dist/core/helper.d.ts.map +1 -1
- package/dist/core/helper.js +14 -0
- package/dist/core/helper.js.map +1 -1
- package/dist/daemon/client/contracts1/connection.d.ts +6 -2
- package/dist/daemon/client/contracts1/connection.d.ts.map +1 -1
- package/dist/daemon/client/contracts1/connection.js +43 -11
- package/dist/daemon/client/contracts1/connection.js.map +1 -1
- package/dist/daemon/client/contracts1/responses.d.ts.map +1 -1
- package/dist/daemon/client/contracts1/responses.js +3 -1
- package/dist/daemon/client/contracts1/responses.js.map +1 -1
- package/dist/daemon/client/contracts2/unix-client.d.ts.map +1 -1
- package/dist/daemon/client/contracts2/unix-client.js +2 -2
- package/dist/daemon/client/contracts2/unix-client.js.map +1 -1
- package/dist/daemon/commands/data/write.d.ts.map +1 -1
- package/dist/daemon/commands/data/write.js +118 -3
- package/dist/daemon/commands/data/write.js.map +1 -1
- package/dist/daemon/commands/invoke/tool.d.ts +1 -1
- package/dist/daemon/commands/invoke/tool.d.ts.map +1 -1
- package/dist/daemon/commands/invoke/tool.js +21 -4
- package/dist/daemon/commands/invoke/tool.js.map +1 -1
- package/dist/daemon/commands/invoke.d.ts.map +1 -1
- package/dist/daemon/commands/invoke.js +3 -1
- package/dist/daemon/commands/invoke.js.map +1 -1
- package/dist/daemon/hosting/execution/daemon-execution-host.consumer.d.ts.map +1 -1
- package/dist/daemon/hosting/execution/daemon-execution-host.consumer.js +6 -1
- package/dist/daemon/hosting/execution/daemon-execution-host.consumer.js.map +1 -1
- package/dist/daemon/hosting/execution/session-workspace-locator.d.ts +96 -0
- package/dist/daemon/hosting/execution/session-workspace-locator.d.ts.map +1 -0
- package/dist/daemon/hosting/execution/session-workspace-locator.js +162 -0
- package/dist/daemon/hosting/execution/session-workspace-locator.js.map +1 -0
- package/dist/daemon/hosting/execution/workspace-execution-hosts.d.ts +60 -0
- package/dist/daemon/hosting/execution/workspace-execution-hosts.d.ts.map +1 -0
- package/dist/daemon/hosting/execution/workspace-execution-hosts.js +134 -0
- package/dist/daemon/hosting/execution/workspace-execution-hosts.js.map +1 -0
- package/dist/daemon/hosting/execution/workspace-routing-connection.d.ts +60 -0
- package/dist/daemon/hosting/execution/workspace-routing-connection.d.ts.map +1 -0
- package/dist/daemon/hosting/execution/workspace-routing-connection.js +153 -0
- package/dist/daemon/hosting/execution/workspace-routing-connection.js.map +1 -0
- package/dist/daemon/runtime/io/execution.js +2 -2
- package/dist/daemon/runtime/io/execution.js.map +1 -1
- package/dist/daemon/runtime/lifecycle/shutdown.d.ts.map +1 -1
- package/dist/daemon/runtime/lifecycle/shutdown.js +1 -0
- package/dist/daemon/runtime/lifecycle/shutdown.js.map +1 -1
- package/dist/daemon/runtime/lifecycle/startup.d.ts.map +1 -1
- package/dist/daemon/runtime/lifecycle/startup.js +18 -1
- package/dist/daemon/runtime/lifecycle/startup.js.map +1 -1
- package/dist/daemon/runtime/state.d.ts +3 -0
- package/dist/daemon/runtime/state.d.ts.map +1 -1
- package/dist/daemon/runtime/state.js +2 -0
- package/dist/daemon/runtime/state.js.map +1 -1
- package/dist/daemon/services/api/rpc-root.d.ts +1 -0
- package/dist/daemon/services/api/rpc-root.d.ts.map +1 -1
- package/dist/daemon/services/api/rpc-root.js +1 -0
- package/dist/daemon/services/api/rpc-root.js.map +1 -1
- package/dist/schemas/contracts/cli/daemon-execution.worker.entry.d.ts +16 -65
- package/dist/schemas/contracts/cli/daemon-execution.worker.entry.d.ts.map +1 -1
- package/dist/schemas/contracts/core/helper.d.ts +23 -0
- package/dist/schemas/contracts/core/helper.d.ts.map +1 -1
- package/dist/schemas/contracts/core/helper.js.map +1 -1
- package/dist/schemas/contracts/daemon/commands/write.d.ts +1 -0
- package/dist/schemas/contracts/daemon/commands/write.d.ts.map +1 -1
- package/dist/schemas/contracts/daemon/commands/write.js +2 -1
- package/dist/schemas/contracts/daemon/commands/write.js.map +1 -1
- package/dist/schemas/contracts/daemon/hosting/contracts1/daemon-execution-host.consumer.d.ts +6 -0
- package/dist/schemas/contracts/daemon/hosting/contracts1/daemon-execution-host.consumer.d.ts.map +1 -1
- package/dist/schemas/contracts/daemon/hosting/contracts1/daemon-execution-host.consumer.js.map +1 -1
- package/dist/schemas/contracts/daemon/hosting/contracts1/daemon.consumer.d.ts +3 -3
- package/dist/schemas/contracts/daemon/hosting/contracts1/daemon.consumer.d.ts.map +1 -1
- package/dist/schemas/contracts/daemon/hosting/contracts1/daemon.consumer.js.map +1 -1
- package/dist/schemas/contracts/daemon/hosting/contracts1/execution-host.schemas.d.ts +36 -245
- package/dist/schemas/contracts/daemon/hosting/contracts1/execution-host.schemas.d.ts.map +1 -1
- package/dist/schemas/contracts/daemon/hosting/contracts1/execution-host.schemas.js +2 -2
- package/dist/schemas/contracts/daemon/hosting/contracts1/execution-host.schemas.js.map +1 -1
- package/dist/schemas/contracts/transports/messageport/messageport-listener.d.ts +2 -0
- package/dist/schemas/contracts/transports/messageport/messageport-listener.d.ts.map +1 -1
- package/dist/schemas/contracts/transports/relay/wire.d.ts +24 -0
- package/dist/schemas/contracts/transports/relay/wire.d.ts.map +1 -1
- package/dist/schemas/contracts/transports/relay/wire.js +17 -1
- package/dist/schemas/contracts/transports/relay/wire.js.map +1 -1
- package/dist/schemas/daemon/daemon-client.d.ts +10 -1
- package/dist/schemas/daemon/daemon-client.d.ts.map +1 -1
- package/dist/schemas/daemon/daemon-client.js +2 -0
- package/dist/schemas/daemon/daemon-client.js.map +1 -1
- package/dist/schemas/invoke.d.ts +2 -0
- package/dist/schemas/invoke.d.ts.map +1 -1
- package/dist/transports/http/http-implementation.js +1 -1
- package/dist/transports/http/http-implementation.js.map +1 -1
- package/dist/transports/relay/broker/index.d.ts +10 -0
- package/dist/transports/relay/broker/index.d.ts.map +1 -0
- package/dist/transports/relay/broker/index.js +10 -0
- package/dist/transports/relay/broker/index.js.map +1 -0
- package/dist/transports/relay/broker/relay-broker.d.ts +352 -0
- package/dist/transports/relay/broker/relay-broker.d.ts.map +1 -0
- package/dist/transports/relay/broker/relay-broker.js +1417 -0
- package/dist/transports/relay/broker/relay-broker.js.map +1 -0
- package/dist/transports/relay/endpoint.d.ts +36 -0
- package/dist/transports/relay/endpoint.d.ts.map +1 -0
- package/dist/transports/relay/endpoint.js +54 -0
- package/dist/transports/relay/endpoint.js.map +1 -0
- package/dist/transports/relay/index.d.ts +2 -1
- package/dist/transports/relay/index.d.ts.map +1 -1
- package/dist/transports/relay/index.js +3 -1
- package/dist/transports/relay/index.js.map +1 -1
- package/dist/transports/relay/listener/relay-caller.d.ts +16 -0
- package/dist/transports/relay/listener/relay-caller.d.ts.map +1 -0
- package/dist/transports/relay/listener/relay-caller.js +34 -0
- package/dist/transports/relay/listener/relay-caller.js.map +1 -0
- package/dist/transports/relay/listener/relay-consumers.d.ts +2 -1
- package/dist/transports/relay/listener/relay-consumers.d.ts.map +1 -1
- package/dist/transports/relay/listener/relay-consumers.js +15 -10
- package/dist/transports/relay/listener/relay-consumers.js.map +1 -1
- package/dist/transports/relay/listener/relay-listen-session.d.ts.map +1 -1
- package/dist/transports/relay/listener/relay-listen-session.js +21 -1
- package/dist/transports/relay/listener/relay-listen-session.js.map +1 -1
- package/dist/transports/relay/testing/broker-routing.d.ts +9 -0
- package/dist/transports/relay/testing/broker-routing.d.ts.map +1 -1
- package/dist/transports/relay/testing/broker-routing.js +13 -1
- package/dist/transports/relay/testing/broker-routing.js.map +1 -1
- package/dist/transports/relay/testing/fake-broker.js +1 -1
- package/dist/transports/relay/testing/fake-broker.js.map +1 -1
- package/dist/transports/relay/wire.d.ts +18 -1
- package/dist/transports/relay/wire.d.ts.map +1 -1
- package/dist/transports/relay/wire.js +22 -1
- package/dist/transports/relay/wire.js.map +1 -1
- 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
|