@tapflowio/relay 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +1 -1
  2. package/dist/RelayServer.d.ts +299 -2
  3. package/dist/RelayServer.d.ts.map +1 -1
  4. package/dist/RelayServer.js +1050 -177
  5. package/dist/RelayServer.js.map +1 -1
  6. package/dist/SessionManager.d.ts +83 -17
  7. package/dist/SessionManager.d.ts.map +1 -1
  8. package/dist/SessionManager.js +111 -14
  9. package/dist/SessionManager.js.map +1 -1
  10. package/dist/index.d.ts +1 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/lib/cert/index.d.ts +1 -1
  15. package/dist/lib/cert/index.d.ts.map +1 -1
  16. package/dist/lib/cert/index.js +1 -1
  17. package/dist/lib/cert/index.js.map +1 -1
  18. package/dist/lib/cert/parseCert.d.ts +4 -0
  19. package/dist/lib/cert/parseCert.d.ts.map +1 -1
  20. package/dist/lib/cert/parseCert.js +68 -0
  21. package/dist/lib/cert/parseCert.js.map +1 -1
  22. package/dist/lib/trailingRequester.d.ts +23 -0
  23. package/dist/lib/trailingRequester.d.ts.map +1 -0
  24. package/dist/lib/trailingRequester.js +56 -0
  25. package/dist/lib/trailingRequester.js.map +1 -0
  26. package/dist/server.js +4 -2
  27. package/dist/server.js.map +1 -1
  28. package/dist/types.d.ts +2 -38
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/types.js +13 -0
  31. package/dist/types.js.map +1 -1
  32. package/package.json +8 -8
  33. package/public/assets/{AppCenter-B3FO67aw.js → AppCenter-Dg4dscW-.js} +6 -6
  34. package/public/assets/AppCenter-Dg4dscW-.js.br +0 -0
  35. package/public/assets/Default-Dxi8aZ1t.js +1 -0
  36. package/public/assets/Default-Dxi8aZ1t.js.br +0 -0
  37. package/public/assets/Invite-0EwoClX6.js +1 -0
  38. package/public/assets/Invite-0EwoClX6.js.br +0 -0
  39. package/public/assets/MacResources-Dy1M3EqK.js +1 -0
  40. package/public/assets/MacResources-Dy1M3EqK.js.br +0 -0
  41. package/public/assets/NotFound-BoDDivK5.js +1 -0
  42. package/public/assets/NotFound-BoDDivK5.js.br +0 -0
  43. package/public/assets/QASession-DqMj8JL1.js +140 -0
  44. package/public/assets/QASession-DqMj8JL1.js.br +0 -0
  45. package/public/assets/ResetPassword-CXU2bRry.js +1 -0
  46. package/public/assets/ResetPassword-CXU2bRry.js.br +0 -0
  47. package/public/assets/Setup-XXChRxeL.js +1 -0
  48. package/public/assets/Setup-XXChRxeL.js.br +0 -0
  49. package/public/assets/{Team-CBsjuUwh.js → Team-D0bXpmEY.js} +3 -3
  50. package/public/assets/Team-D0bXpmEY.js.br +0 -0
  51. package/public/assets/Tokens-D0YNHS5e.js +1 -0
  52. package/public/assets/Tokens-D0YNHS5e.js.br +0 -0
  53. package/public/assets/alert-dialog-CgatMXjB.js +1 -0
  54. package/public/assets/alert-dialog-CgatMXjB.js.br +0 -0
  55. package/public/assets/badge-B6Nf3A0Y.js +1 -0
  56. package/public/assets/badge-B6Nf3A0Y.js.br +0 -0
  57. package/public/assets/{build-format-B78hbKY9.js → build-format-BFC8gLoh.js} +5 -5
  58. package/public/assets/build-format-BFC8gLoh.js.br +0 -0
  59. package/public/assets/{dialog-BxC9v2BL.js → dialog-DK9a3b4z.js} +5 -5
  60. package/public/assets/dialog-DK9a3b4z.js.br +0 -0
  61. package/public/assets/index-9vdYIm7s.js +210 -0
  62. package/public/assets/index-9vdYIm7s.js.br +0 -0
  63. package/public/assets/index-ClHUQm-F.css +1 -0
  64. package/public/assets/index-ClHUQm-F.css.br +0 -0
  65. package/public/assets/{pencil-Cq64I9LV.js → pencil-BeLNLEKe.js} +2 -2
  66. package/public/assets/pencil-BeLNLEKe.js.br +0 -0
  67. package/public/assets/table-DW57-o1p.js +1 -0
  68. package/public/assets/table-DW57-o1p.js.br +0 -0
  69. package/public/assets/tabs-Xlttuqxh.js +1 -0
  70. package/public/assets/tabs-Xlttuqxh.js.br +0 -0
  71. package/public/assets/{trash-2-CszwJHEh.js → trash-2-CGxISX8x.js} +3 -3
  72. package/public/assets/trash-2-CGxISX8x.js.br +0 -0
  73. package/public/assets/vendor-charts-DyX5CBcf.js +5 -0
  74. package/public/assets/vendor-charts-DyX5CBcf.js.br +0 -0
  75. package/public/assets/vendor-react-AJpf4YZ0.js +49 -0
  76. package/public/assets/vendor-react-AJpf4YZ0.js.br +0 -0
  77. package/public/assets/vendor-router-ChFy8CZ1.js +12 -0
  78. package/public/assets/vendor-router-ChFy8CZ1.js.br +0 -0
  79. package/public/index.html +4 -2
  80. package/public/index.html.br +0 -0
  81. package/public/assets/AppCenter-B3FO67aw.js.br +0 -0
  82. package/public/assets/Default-BlNUv5dw.js +0 -1
  83. package/public/assets/Default-BlNUv5dw.js.br +0 -0
  84. package/public/assets/Invite-CKYFmy7S.js +0 -1
  85. package/public/assets/Invite-CKYFmy7S.js.br +0 -0
  86. package/public/assets/MacResources-BK4VQnFc.js +0 -5
  87. package/public/assets/MacResources-BK4VQnFc.js.br +0 -0
  88. package/public/assets/NotFound-BMmi1paB.js +0 -1
  89. package/public/assets/NotFound-BMmi1paB.js.br +0 -0
  90. package/public/assets/QASession-CVTlqW3d.js +0 -125
  91. package/public/assets/QASession-CVTlqW3d.js.br +0 -0
  92. package/public/assets/ResetPassword-VPPBWzSI.js +0 -1
  93. package/public/assets/ResetPassword-VPPBWzSI.js.br +0 -0
  94. package/public/assets/Setup-CRdnCl41.js +0 -1
  95. package/public/assets/Setup-CRdnCl41.js.br +0 -0
  96. package/public/assets/Team-CBsjuUwh.js.br +0 -0
  97. package/public/assets/Tokens-CU9HB9WD.js +0 -1
  98. package/public/assets/Tokens-CU9HB9WD.js.br +0 -0
  99. package/public/assets/alert-dialog-DWqGDQTB.js +0 -1
  100. package/public/assets/alert-dialog-DWqGDQTB.js.br +0 -0
  101. package/public/assets/badge-OToVq1dK.js +0 -1
  102. package/public/assets/badge-OToVq1dK.js.br +0 -0
  103. package/public/assets/build-format-B78hbKY9.js.br +0 -3
  104. package/public/assets/dialog-BxC9v2BL.js.br +0 -0
  105. package/public/assets/index-DXjMx9hQ.js +0 -274
  106. package/public/assets/index-DXjMx9hQ.js.br +0 -0
  107. package/public/assets/index-uGJGo6ac.css +0 -1
  108. package/public/assets/index-uGJGo6ac.css.br +0 -0
  109. package/public/assets/pencil-Cq64I9LV.js.br +0 -0
  110. package/public/assets/table-CbATN5Pi.js +0 -1
  111. package/public/assets/table-CbATN5Pi.js.br +0 -0
  112. package/public/assets/tabs-BVumgDA4.js +0 -1
  113. package/public/assets/tabs-BVumgDA4.js.br +0 -0
  114. package/public/assets/trash-2-CszwJHEh.js.br +0 -0
package/README.md CHANGED
@@ -6,7 +6,7 @@ Handles NAT traversal between browser clients and device agents, session routing
6
6
 
7
7
  ## Requirements
8
8
 
9
- - Node.js ≥ 20
9
+ - Node.js ≥ 22
10
10
 
11
11
  ## Usage
12
12
 
@@ -1,4 +1,31 @@
1
1
  export declare function isExternalAddress(addr: string): boolean;
2
+ /** Exported for the re-join test, which pins both edges of this window rather than hardcoding 500 —
3
+ * a test carrying its own copy of the number passes when the constant moves, which is the drift the
4
+ * wire-contract work keeps finding. Not re-exported from the package index. */
5
+ export declare const IDR_REQUEST_THROTTLE_MS = 500;
6
+ /** The same window for `network:request-state`, and a **separate constant on purpose**: the two share
7
+ * a number today and not a policy. IDR drops inside the window; this one coalesces onto its trailing
8
+ * edge (see `networkStateRequester`). Sharing the constant would mean tuning one tunes the other. */
9
+ export declare const NETWORK_STATE_REQUEST_THROTTLE_MS = 500;
10
+ /**
11
+ * The owner key a connection speaks for, from the authenticated user and the client id it claimed.
12
+ *
13
+ * **Both halves, and a pure function so both can be tested.** The user id is what keeps a leaked client id
14
+ * useless to anyone else — without it this would be the relay's first ownership claim a caller can forge,
15
+ * where socket identity could not be. It comes from the cookie *or* the PAT: reading only the cookie would
16
+ * have made every agent and every non-browser client `anon:`, which is the population most likely to have
17
+ * its handshake URL end up in a reverse proxy's access log. The mint is what removes the fallback branch: a connection that
18
+ * claims nothing gets an identity of its own, which is per-socket, which is the behaviour ownership had
19
+ * before it moved off the socket.
20
+ *
21
+ * Exported for the test that holds the pairing. Inline in the handler it was unreachable without standing
22
+ * up an authenticated handshake, and a mutation dropping the user id survived the whole suite.
23
+ */
24
+ export declare function ownerKeyFor(userId: number | string | undefined, claimed: string | null): {
25
+ key: string;
26
+ user: string;
27
+ minted: boolean;
28
+ };
2
29
  export declare class RelayServer {
3
30
  private readonly options;
4
31
  private httpServer;
@@ -15,12 +42,47 @@ export declare class RelayServer {
15
42
  private purgeBuildsTimer;
16
43
  private flushResourcesTimer;
17
44
  private heartbeatTimer;
18
- private wsAlive;
45
+ /**
46
+ * When each socket last answered a ping. WeakMap → no manual cleanup on close (GC handles it).
47
+ *
48
+ * **A timestamp, not the swept boolean it replaced.** That flag was set `false` for *every* socket at the
49
+ * start of each sweep and back to `true` only when the pong returned, so a perfectly healthy socket read
50
+ * as dead for the whole round trip — a normal state on a 30s cycle rather than an edge case. And the
51
+ * window is longest for exactly the socket that matters: video goes out on `browserSocket`, and the ping
52
+ * is written behind whatever is already queued there, so an external viewer's pong can take a second or
53
+ * two.
54
+ *
55
+ * That was invisible while the flag only decided termination, because a sweep that finds `false` has by
56
+ * definition already waited a full interval. Reading it to answer *"is this holder alive right now"* is
57
+ * what exposes it: occupancy would hand a live tester's session away, and `busy` would flash a device as
58
+ * free every 30 seconds — the dashboard polls every 5s and `disabled={isBusy}`, so the UI would open the
59
+ * button rather than a race being needed.
60
+ */
61
+ private lastPongAt;
62
+ /**
63
+ * Who each socket speaks for — `<userId>:<clientId>`, the key a session's ownership is compared against.
64
+ *
65
+ * **Two parts, and both are load-bearing.** The client id is supplied by the caller (`?client=`), so on
66
+ * its own it is the relay's first *forgeable* ownership claim: today's ownership is socket identity,
67
+ * which nothing can spoof. Binding it to the authenticated user — which `getAuth` already resolves at
68
+ * the handshake — keeps a leaked id useless to anyone else's account.
69
+ *
70
+ * **A connection that supplies no client id is given one**, rather than falling back to socket identity.
71
+ * There is deliberately no fallback branch: one model, and a client that does not identify itself gets an
72
+ * identity that happens to be per-socket, which is exactly today's behaviour.
73
+ */
74
+ private ownerKey;
19
75
  private dropHandlers;
20
76
  private audioDropHandlers;
21
77
  private droppers;
22
78
  private idrRequesters;
79
+ /** Unlike `idrRequesters` these hold a timer, which is why the value is not a bare closure — see
80
+ * `forgetSessionState`. */
81
+ private networkStateRequesters;
23
82
  private wsRoles;
83
+ /** Per-socket throttle state for `logInboundRejection`. A `WeakMap` so a closed socket's entry goes
84
+ * with the socket — there is no cleanup to forget, unlike the maps keyed by session id nearby. */
85
+ private readonly rejectionLog;
24
86
  private agentHolds;
25
87
  private stopping;
26
88
  private wsExternal;
@@ -54,14 +116,123 @@ export declare class RelayServer {
54
116
  stop(): Promise<void>;
55
117
  address(): string | import("net").AddressInfo | null;
56
118
  private runHeartbeat;
119
+ /**
120
+ * Has this socket answered recently enough to be treated as present?
121
+ *
122
+ * `1.5` intervals rather than one: a pong that arrives *during* a sweep must not be read as late, and a
123
+ * socket pinged at the end of one interval has until halfway through the next. The cost is that a
124
+ * genuinely dead socket is recognised in up to 45s instead of 30 — **and 45 seconds with no false
125
+ * negative beats 30 with one every cycle**, because a false negative here does not merely delay: it
126
+ * hands a live tester's session to whoever asked next.
127
+ */
128
+ private isAlive;
57
129
  updateTlsContext(material: {
58
130
  cert: string;
59
131
  key: string;
60
132
  }): void;
61
- private makeIdrRequester;
133
+ /**
134
+ * Drop every per-session stream record keyed by this id.
135
+ *
136
+ * **Four maps that have to move together, called from four places that did not.** They were deleted
137
+ * inline in `session:end` and `session:leave` and nowhere else, so a session ending any other way —
138
+ * the agent going away, the idle timer, a device disappearing from a re-register — left all four
139
+ * behind for the life of the process.
140
+ *
141
+ * Pre-existing, and this slice is what makes it worth fixing here rather than filing: `idrRequesters`
142
+ * used to be populated only by the binary drop path, i.e. only under backpressure, and a re-join now
143
+ * creates an entry for any streaming session (#515). The leak went from rare to ordinary.
144
+ *
145
+ * **Now five maps, and the fifth is not dropped the same way.** `networkStateRequesters` holds a
146
+ * *coalescing* requester, so its closure owns a pending `setTimeout` — and deleting a map entry does
147
+ * not cancel a timer. Leaving it armed is not a leak but a double send: `session:leave` followed by a
148
+ * re-join inside the window fires the orphaned trailing edge *and* the new requester's leading edge,
149
+ * which is the second budget for one session that this map exists to prevent. Hence `dispose()`
150
+ * rather than `delete` alone. It was named `forgetSessionStreamState` while all four were stream
151
+ * records; the network requester is not one, so the name lost the word rather than the entry
152
+ * lost the function.
153
+ */
154
+ private forgetSessionState;
155
+ /**
156
+ * Throttled callback asking the session's agent for an on-demand IDR (fast drop recovery); ignored by
157
+ * agents that don't support it.
158
+ *
159
+ * **One per session, and the construction is folded in here on purpose.** The throttle is `lastAt` in
160
+ * the closure, so a second requester for the same session is a second budget — which is what the
161
+ * `idrRequesters` map exists to prevent, and it prevented it only as long as every caller remembered
162
+ * to look the session up first. There are two callers now (the drop path and the re-join in
163
+ * `handleSessionStart`), and a separate `makeIdrRequester` was a factory either of them could reach
164
+ * past the map. Measured: calling it directly from one caller left every test green.
165
+ */
166
+ private idrRequester;
167
+ /**
168
+ * Throttled callback asking the session's agent to re-read and report the device's network
169
+ * condition (#614); ignored by agents that don't support it.
170
+ *
171
+ * **Coalescing, where `idrRequester` above drops — and the difference is not a preference.** A
172
+ * dropped IDR request costs nothing: the next periodic keyframe repairs it. Nothing re-produces a
173
+ * `network:state`, so a request dropped inside the window leaves that viewer rendering "unknown"
174
+ * for the life of the session. The trailing edge fires after the last join in a burst, and the
175
+ * relay addresses the reply to whichever socket holds the session *when it arrives* — so the
176
+ * viewer that ends up watching is the one served, which a leading edge cannot promise.
177
+ *
178
+ * The burst this is for is the dirty blip, not two people: `join()` refuses a live holder, and a
179
+ * clean `session:leave` runs `forgetSessionState` and builds a fresh requester. A browser socket
180
+ * that dies in its close handler does neither, so `lastAt` survives into the re-join — which is
181
+ * the very path this slice exists to serve.
182
+ *
183
+ * One per session, constructed here, for the reason written on `idrRequester`: the throttle lives
184
+ * in the closure, so a second requester is a second budget.
185
+ */
186
+ private networkStateRequester;
62
187
  private remoteAddressOf;
63
188
  private warnProxyMisconfigOnce;
64
189
  private handleConnection;
190
+ /**
191
+ * @param msg what the door proved — see `parseInbound`. An Envelope-tier member arrives carrying
192
+ * `type` and its correlators and nothing else, so a payload the parser did not check
193
+ * cannot be read off it.
194
+ * @param raw the frame as it arrived. Agent-origin messages are **forwarded as this**, so a field
195
+ * a newer agent added survives a relay that does not know it — `z.object` strips, and
196
+ * stripping is wrong in the one direction where the sender is the more recently
197
+ * updated side. Browser-origin messages are forwarded as `msg` instead, so a key an attacker
198
+ * appended does not survive. It is also where the two stored Envelope payloads come
199
+ * from, which is the point: a value read off `raw` is one the parser did not vouch for.
200
+ */
201
+ /**
202
+ * Assign this socket's role if it does not have one, then refuse a message its role may not send.
203
+ *
204
+ * @returns `false` when the caller must stop — the socket was closed, or the frame is a failed
205
+ * handshake that must not confer a role.
206
+ */
207
+ private settleRole;
208
+ /**
209
+ * What the door refused, said once and in a form an operator can act on.
210
+ *
211
+ * This is where `isAddressed` and `isCorrelated` ended up. Both were predicates whose whole
212
+ * observable output was a `console.warn` — an id-less request resolved no session and was dropped by
213
+ * the miss anyway — and the schemas now reject the same frames earlier, including the empty-string
214
+ * case a bare `z.string()` would have let through. What is new is that the log names the *field*:
215
+ * "requestId: Too small" instead of "dropped, cannot correlate a reply".
216
+ *
217
+ * Worth logging at all for the reason `isCorrelated` gave: the three places a bad frame can be
218
+ * dropped are otherwise silent, and an operator who upgrades the relay but not an independently
219
+ * installed `mcp-server` would watch commands do nothing with no trace.
220
+ *
221
+ * **Throttled per socket, and the "per socket" is the whole design.** A first draft wrote one line
222
+ * per rejected frame, on the direction a viewer with devtools controls and at the rate a gesture
223
+ * produces — the unbounded, attacker-driven log volume this file already refuses at
224
+ * `forwardUnacked`, with the reason written beside it. A single module-level timestamp would fix the
225
+ * volume and break the diagnostic: one noisy socket would silence the *other* socket's first bad
226
+ * frame, which is the skewed-client case the log exists for. So the state is keyed by socket, in a
227
+ * `WeakMap` that needs no cleanup, and **the first rejection from any socket is always written** —
228
+ * the one that names the skew is never the hundredth. What is dropped is only repetition, and the
229
+ * next line says how much.
230
+ *
231
+ * `unknown-type` stays at debug: eleven relay-produced literals land here whenever a client echoes
232
+ * one back, and none of them is a defect. It shares the throttle so turning debug on cannot
233
+ * reintroduce the volume.
234
+ */
235
+ private logInboundRejection;
65
236
  private route;
66
237
  private handleAgentResources;
67
238
  /**
@@ -104,7 +275,133 @@ export declare class RelayServer {
104
275
  */
105
276
  private evictAgentSocket;
106
277
  private handleAgentRegister;
278
+ /** The only producer of `error` — all five exits below, and nothing else in the repo sends that message.
279
+ *
280
+ * That is what makes the address possible rather than aspirational: `msg.sessionId` is narrowed to a
281
+ * non-empty `string` by the door — the inbound schema, `isAddressed` before it — so every refusal can
282
+ * name the join it refuses. Before
283
+ * L5d they carried none, and the clients' join waiters matched `sessionId === undefined || sessionId ===
284
+ * mine` — with no such key the left half was always true, so any refusal resolved any pending join. */
107
285
  private handleSessionStart;
286
+ /** Shut the device down because nobody is watching it any more. The idle timer's payload, hoisted out
287
+ * of the close handler so the release loop there reads as one line per session. */
288
+ private idleShutdown;
289
+ /** Where a browser-role command can be dispatched, or what its sender needs to be told instead.
290
+ *
291
+ * Three refusals used to be spread across seven `case` bodies as two conditions each, and the ownership
292
+ * one was in none of them. Collapsed here so the set is decided once: a command reaches an agent only if
293
+ * the session exists, **this socket holds it**, and the agent is connected. Anything else has prose the
294
+ * caller can act on, and each case wraps that prose in the reply type its own waiter reads.
295
+ *
296
+ * The two ownership strings are one reason with different prose, the treatment `#492` settled: telling a
297
+ * caller the session is in use when it is idle steers it off a device it could have had.
298
+ */
299
+ private dispatchTarget;
300
+ /** `dispatchTarget` without the ownership clause: is there a session here, and is its agent listening?
301
+ *
302
+ * **The name carries the omission because a doc block does not reach an autocomplete.** This is the
303
+ * resolver a future handler must not pick by accident — it is the general-looking one and the unsafe
304
+ * one at the same time, which is the pairing worth spelling out.
305
+ *
306
+ * **Still here after #527, and this paragraph used to say it would not be.** It predicted that
307
+ * answering "who may shut a device down" would move `device:shutdown` up to `dispatchTarget`. The
308
+ * answer turned out to be a *different* gate rather than that one: `mayShutDown` refuses a session
309
+ * another client holds and permits an unheld one, because the dashboard's unmount teardown races its
310
+ * own viewer socket's close and an owns-it gate refuses the teardown whenever the close lands first —
311
+ * leaving the device booted until the idle timer, which is the cost #527 was filed to avoid.
312
+ *
313
+ * So the two are ordered rather than merged: `mayShutDown` decides *who*, this decides *whether it can
314
+ * be delivered at all*, and without the second a shutdown addressed to a missing session or a closed
315
+ * agent socket is dropped in silence and `mcp-server`'s caller burns a 30s deadline (#542).
316
+ *
317
+ * The disclosure it was written to record stands: a caller that is not the holder still learns whether
318
+ * a session id exists and whether its agent is up.
319
+ */
320
+ private reachableTargetWithoutOwnership;
321
+ /** Is `ws` the socket this session is bound to?
322
+ *
323
+ * Until L5c the input path resolved the session and forwarded without asking. `clipboard:data` asks the
324
+ * mirror-image question one branch up (`session?.agentSocket !== ws`) with the reason written beside it —
325
+ * a second agent on the same relay must not address someone else's session — and the browser direction
326
+ * had no equivalent. So any authenticated client that knew a session id could drive a device another
327
+ * tester was looking at, and the agent's ack went to the session holder rather than to whoever asked.
328
+ *
329
+ * An **unheld** session (no `browserSocket`, or one that has gone) is also not owned by the sender. That
330
+ * is deliberate rather than incidental: it makes an input arriving inside the reconnect grace fail fast
331
+ * instead of being applied to a device with nobody watching the result. */
332
+ private ownsSession;
333
+ /** Which owner this socket speaks for. Every connection has one — supplied or minted — so this never
334
+ * answers `undefined` and two anonymous sockets can never match by both lacking an identity. */
335
+ private ownerOf;
336
+ /** The principal behind a socket — a user id, or `anon` for a connection carrying no cookie and no PAT. */
337
+ private userOf;
338
+ /** Everything the bind needs to record about who is joining.
339
+ *
340
+ * **A socket with no record gets one of its own rather than a shared sentinel.** `handleConnection`
341
+ * seeds every accepted connection before a message can arrive, so the fallback is unreachable today —
342
+ * but a literal would make any two unseeded sockets *equal*, and equal keys pass `ownsSession` while
343
+ * equal users pass `mayShutDown`'s same-principal exemption. That rests the whole ownership invariant
344
+ * on one call site staying total, which is the shape of dependency the sentinel is supposed to remove.
345
+ * Minting keeps the answer "this socket and no other"; storing it keeps repeat calls consistent, since
346
+ * a fresh value each time would fail to recognise the very socket that bound the session. `minted` is
347
+ * `false` deliberately — the legacy exemption exists for a client that identified itself with nothing,
348
+ * not for one the relay failed to record. */
349
+ private ownerRecord;
350
+ /**
351
+ * May this socket shut the session's device down?
352
+ *
353
+ * **"Not someone else's" rather than "mine", and the difference is the reason #527 stayed open.** The
354
+ * dashboard's unmount teardown sends `device:shutdown` from one socket while another socket's `close`
355
+ * races it to the relay — two connections, no ordering — and if the close lands first the session is
356
+ * unheld. An owns-it gate refuses the teardown there, and the device stays booted until the five-minute
357
+ * idle timer: the path #527 named as the reason a gate could not be added at all.
358
+ *
359
+ * An unheld session therefore stays shutdownable by anyone authenticated, which is unchanged from today
360
+ * — this command has never had a gate — and is written down rather than implied. What changes is the
361
+ * case the issue is about: a device another tester is *holding* can no longer be powered off.
362
+ */
363
+ private mayShutDown;
364
+ /** Input that no ack answers: opening and move frames, rotation, the keyboard-forwarding toggle.
365
+ *
366
+ * Dropped rather than refused when unowned, and that asymmetry with `handleAckedInput` is the whole
367
+ * reason these have their own clause: refusing means answering, and there is no waiter here to answer.
368
+ * `clipboard:data`'s silent `break` is the precedent that fits — a frame nobody is waiting on. */
369
+ private forwardUnacked;
370
+ /** The five inputs an ack answers, so the five that carry a required correlator.
371
+ *
372
+ * Every exit answers the sender. That is the rule this method exists to hold: a request with a waiter on
373
+ * a 2s deadline must not be dropped silently, because the caller's fallback reports silence from a
374
+ * session that has never acked as **success** (#457) — so a silent drop here would report an input that
375
+ * never left the relay as landed, which is worse than the misrouting it replaced. */
376
+ private handleAckedInput;
377
+ /**
378
+ * Tells the sender its payload was refused, in the shape that request's own waiter reads.
379
+ *
380
+ * **This is what keeps the door from turning an answered failure into silence.** Before it, a
381
+ * malformed `open-url` reached the agent and the agent's own guard answered `open-url:error`;
382
+ * `IOSAgent.ts` says so beside that guard, and names this validation as what would take it over.
383
+ * Taking the responsibility without taking the answer would have been a regression — worst on the
384
+ * inputs, and not obviously: `awaitInputAck` reports silence from a session that has never acked as
385
+ * **success** (#457), so a dropped `input:key` reads to an MCP caller as an input that landed.
386
+ *
387
+ * The address and the correlator come from the envelope, which `parseInbound` judges separately from
388
+ * the payload for exactly this reason — a frame with a good envelope carries everything a reply
389
+ * needs. `reason: 'malformed'` is not a new member; the input vocabulary already had it, and until
390
+ * now only agents produced it.
391
+ *
392
+ * No ownership check, deliberately: the reply goes to the socket that sent the frame and says only
393
+ * that its own message was malformed, so it discloses nothing about the session. Every other refusal
394
+ * in this file answers a question about the session's state and is gated for that reason.
395
+ */
396
+ private refuseMalformed;
397
+ /** Answers the sender in the shape its waiter is keyed on.
398
+ *
399
+ * `input:type` needs `input:type-error`, not `input:error` — its waiters in `mcp-server` and
400
+ * `flow-runner` match on the `input:type-*` pair and ignore an `input:error` entirely. That is why
401
+ * adding `input:type` to the terminal set was never the fix for it, and answering it properly here is
402
+ * closes the gap `relay/AGENTS.md` recorded: before L5c an `input:type` on an offline agent got nothing
403
+ * and burned its caller's full deadline. */
404
+ private refuseInput;
108
405
  /** Relay looks up file_path from DB and enriches it for the agent.
109
406
  *
110
407
  * Every exit carries the request's `sessionId`, including the failures. A dashboard viewer holds
@@ -1 +1 @@
1
- {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}