@tapflowio/relay 0.24.0 → 0.26.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 (164) hide show
  1. package/dist/RelayServer.d.ts +51 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +262 -31
  4. package/dist/RelayServer.js.map +1 -1
  5. package/dist/api/apps.d.ts.map +1 -1
  6. package/dist/api/apps.js +6 -16
  7. package/dist/api/apps.js.map +1 -1
  8. package/dist/api/auth.d.ts +1 -1
  9. package/dist/api/auth.d.ts.map +1 -1
  10. package/dist/api/auth.js +30 -19
  11. package/dist/api/auth.js.map +1 -1
  12. package/dist/api/builds.d.ts.map +1 -1
  13. package/dist/api/builds.js +7 -5
  14. package/dist/api/builds.js.map +1 -1
  15. package/dist/api/comments.d.ts.map +1 -1
  16. package/dist/api/comments.js +41 -15
  17. package/dist/api/comments.js.map +1 -1
  18. package/dist/api/invitations.d.ts +2 -0
  19. package/dist/api/invitations.d.ts.map +1 -1
  20. package/dist/api/invitations.js +17 -17
  21. package/dist/api/invitations.js.map +1 -1
  22. package/dist/api/passwordReset.d.ts +13 -2
  23. package/dist/api/passwordReset.d.ts.map +1 -1
  24. package/dist/api/passwordReset.js +47 -12
  25. package/dist/api/passwordReset.js.map +1 -1
  26. package/dist/api/team.d.ts +2 -2
  27. package/dist/api/team.d.ts.map +1 -1
  28. package/dist/api/team.js +15 -5
  29. package/dist/api/team.js.map +1 -1
  30. package/dist/api/tokens.d.ts +1 -1
  31. package/dist/api/tokens.d.ts.map +1 -1
  32. package/dist/api/tokens.js +33 -8
  33. package/dist/api/tokens.js.map +1 -1
  34. package/dist/api/webhooks.d.ts.map +1 -1
  35. package/dist/api/webhooks.js +11 -5
  36. package/dist/api/webhooks.js.map +1 -1
  37. package/dist/lib/adminAccount.d.ts +0 -5
  38. package/dist/lib/adminAccount.d.ts.map +1 -1
  39. package/dist/lib/adminAccount.js +6 -7
  40. package/dist/lib/adminAccount.js.map +1 -1
  41. package/dist/lib/clientAddress.d.ts +7 -0
  42. package/dist/lib/clientAddress.d.ts.map +1 -1
  43. package/dist/lib/clientAddress.js +15 -0
  44. package/dist/lib/clientAddress.js.map +1 -1
  45. package/dist/lib/connectionAuth.d.ts +58 -2
  46. package/dist/lib/connectionAuth.d.ts.map +1 -1
  47. package/dist/lib/connectionAuth.js +66 -6
  48. package/dist/lib/connectionAuth.js.map +1 -1
  49. package/dist/lib/email.d.ts +16 -0
  50. package/dist/lib/email.d.ts.map +1 -0
  51. package/dist/lib/email.js +18 -0
  52. package/dist/lib/email.js.map +1 -0
  53. package/dist/middleware/auth.d.ts +63 -4
  54. package/dist/middleware/auth.d.ts.map +1 -1
  55. package/dist/middleware/auth.js +127 -28
  56. package/dist/middleware/auth.js.map +1 -1
  57. package/dist/migrations/014_normalize_emails.sql +16 -0
  58. package/package.json +3 -3
  59. package/public/assets/AppCenter-DZCsC2wa.js +26 -0
  60. package/public/assets/AppCenter-DZCsC2wa.js.br +0 -0
  61. package/public/assets/AppCenter-DZCsC2wa.js.gz +0 -0
  62. package/public/assets/Default-xD9Fmd5X.js +1 -0
  63. package/public/assets/Default-xD9Fmd5X.js.br +0 -0
  64. package/public/assets/Default-xD9Fmd5X.js.gz +0 -0
  65. package/public/assets/Invite-29Erwv8j.js +1 -0
  66. package/public/assets/Invite-29Erwv8j.js.br +0 -0
  67. package/public/assets/Invite-29Erwv8j.js.gz +0 -0
  68. package/public/assets/{MacResources-fCn40Tf5.js → MacResources-B0DQ9ISE.js} +1 -1
  69. package/public/assets/MacResources-B0DQ9ISE.js.br +0 -0
  70. package/public/assets/MacResources-B0DQ9ISE.js.gz +0 -0
  71. package/public/assets/QASession-D-n6PXuD.js +150 -0
  72. package/public/assets/QASession-D-n6PXuD.js.br +0 -0
  73. package/public/assets/QASession-D-n6PXuD.js.gz +0 -0
  74. package/public/assets/{ResetPassword-D-SFg4ap.js → ResetPassword-BnPIwTYH.js} +1 -1
  75. package/public/assets/ResetPassword-BnPIwTYH.js.br +0 -0
  76. package/public/assets/ResetPassword-BnPIwTYH.js.gz +0 -0
  77. package/public/assets/{Setup-ULjvIfAi.js → Setup-DmUGFblg.js} +1 -1
  78. package/public/assets/Setup-DmUGFblg.js.br +0 -0
  79. package/public/assets/Setup-DmUGFblg.js.gz +0 -0
  80. package/public/assets/Team-BpvRgFfZ.js +6 -0
  81. package/public/assets/Team-BpvRgFfZ.js.br +0 -0
  82. package/public/assets/Team-BpvRgFfZ.js.gz +0 -0
  83. package/public/assets/Tokens-x6BtCcL7.js +1 -0
  84. package/public/assets/Tokens-x6BtCcL7.js.br +0 -0
  85. package/public/assets/Tokens-x6BtCcL7.js.gz +0 -0
  86. package/public/assets/alert-dialog-C8wDimW-.js +1 -0
  87. package/public/assets/alert-dialog-C8wDimW-.js.br +0 -0
  88. package/public/assets/alert-dialog-C8wDimW-.js.gz +0 -0
  89. package/public/assets/{badge-B4QAWrzA.js → badge-CWVbeuH3.js} +1 -1
  90. package/public/assets/badge-CWVbeuH3.js.br +0 -0
  91. package/public/assets/badge-CWVbeuH3.js.gz +0 -0
  92. package/public/assets/{build-format-CtmRXihN.js → build-format-fzt-JrUX.js} +1 -1
  93. package/public/assets/build-format-fzt-JrUX.js.br +0 -0
  94. package/public/assets/build-format-fzt-JrUX.js.gz +0 -0
  95. package/public/assets/{dialog-BAFN_ONP.js → dialog-DQkbaw8h.js} +3 -3
  96. package/public/assets/dialog-DQkbaw8h.js.br +0 -0
  97. package/public/assets/dialog-DQkbaw8h.js.gz +0 -0
  98. package/public/assets/{index-COOWQXpW.js → index-C1Fo8g_c.js} +33 -33
  99. package/public/assets/index-C1Fo8g_c.js.br +0 -0
  100. package/public/assets/index-C1Fo8g_c.js.gz +0 -0
  101. package/public/assets/index-C2F1AgwB.css +1 -0
  102. package/public/assets/index-C2F1AgwB.css.br +0 -0
  103. package/public/assets/{index-ozCdBYxk.css.gz → index-C2F1AgwB.css.gz} +0 -0
  104. package/public/assets/{list-view-Rrqp8TkG.js → list-view-DxI397-H.js} +1 -1
  105. package/public/assets/list-view-DxI397-H.js.br +0 -0
  106. package/public/assets/list-view-DxI397-H.js.gz +0 -0
  107. package/public/assets/{pencil-CAHyK7Qf.js → pencil-BJcOp3oN.js} +1 -1
  108. package/public/assets/pencil-BJcOp3oN.js.br +0 -0
  109. package/public/assets/pencil-BJcOp3oN.js.gz +0 -0
  110. package/public/assets/resource-health-Cq3gqqVd.js +1 -0
  111. package/public/assets/resource-health-Cq3gqqVd.js.br +0 -0
  112. package/public/assets/resource-health-Cq3gqqVd.js.gz +0 -0
  113. package/public/assets/{trash-CkO-5TRA.js → trash-DhBxh6H7.js} +1 -1
  114. package/public/assets/trash-DhBxh6H7.js.br +0 -0
  115. package/public/assets/trash-DhBxh6H7.js.gz +0 -0
  116. package/public/index.html +2 -2
  117. package/public/index.html.br +0 -0
  118. package/public/index.html.gz +0 -0
  119. package/public/assets/AppCenter-BHz6vh5p.js +0 -26
  120. package/public/assets/AppCenter-BHz6vh5p.js.br +0 -0
  121. package/public/assets/AppCenter-BHz6vh5p.js.gz +0 -0
  122. package/public/assets/Default-bCqU0Qz9.js +0 -1
  123. package/public/assets/Default-bCqU0Qz9.js.br +0 -0
  124. package/public/assets/Default-bCqU0Qz9.js.gz +0 -0
  125. package/public/assets/Invite-EVMTsuNz.js +0 -1
  126. package/public/assets/Invite-EVMTsuNz.js.br +0 -0
  127. package/public/assets/Invite-EVMTsuNz.js.gz +0 -0
  128. package/public/assets/MacResources-fCn40Tf5.js.br +0 -0
  129. package/public/assets/MacResources-fCn40Tf5.js.gz +0 -0
  130. package/public/assets/QASession-CL-eyFxG.js +0 -150
  131. package/public/assets/QASession-CL-eyFxG.js.br +0 -0
  132. package/public/assets/QASession-CL-eyFxG.js.gz +0 -0
  133. package/public/assets/ResetPassword-D-SFg4ap.js.br +0 -0
  134. package/public/assets/ResetPassword-D-SFg4ap.js.gz +0 -0
  135. package/public/assets/Setup-ULjvIfAi.js.br +0 -0
  136. package/public/assets/Setup-ULjvIfAi.js.gz +0 -0
  137. package/public/assets/Team-DH91GD4w.js +0 -6
  138. package/public/assets/Team-DH91GD4w.js.br +0 -0
  139. package/public/assets/Team-DH91GD4w.js.gz +0 -0
  140. package/public/assets/Tokens-DImLpOVZ.js +0 -1
  141. package/public/assets/Tokens-DImLpOVZ.js.br +0 -0
  142. package/public/assets/Tokens-DImLpOVZ.js.gz +0 -0
  143. package/public/assets/alert-dialog-CwcUyDZQ.js +0 -1
  144. package/public/assets/alert-dialog-CwcUyDZQ.js.br +0 -0
  145. package/public/assets/alert-dialog-CwcUyDZQ.js.gz +0 -0
  146. package/public/assets/badge-B4QAWrzA.js.br +0 -0
  147. package/public/assets/badge-B4QAWrzA.js.gz +0 -0
  148. package/public/assets/build-format-CtmRXihN.js.br +0 -0
  149. package/public/assets/build-format-CtmRXihN.js.gz +0 -0
  150. package/public/assets/dialog-BAFN_ONP.js.br +0 -0
  151. package/public/assets/dialog-BAFN_ONP.js.gz +0 -0
  152. package/public/assets/index-COOWQXpW.js.br +0 -0
  153. package/public/assets/index-COOWQXpW.js.gz +0 -0
  154. package/public/assets/index-ozCdBYxk.css +0 -1
  155. package/public/assets/index-ozCdBYxk.css.br +0 -0
  156. package/public/assets/list-view-Rrqp8TkG.js.br +0 -0
  157. package/public/assets/list-view-Rrqp8TkG.js.gz +0 -0
  158. package/public/assets/pencil-CAHyK7Qf.js.br +0 -0
  159. package/public/assets/pencil-CAHyK7Qf.js.gz +0 -0
  160. package/public/assets/resource-health-BRpSwarg.js +0 -1
  161. package/public/assets/resource-health-BRpSwarg.js.br +0 -0
  162. package/public/assets/resource-health-BRpSwarg.js.gz +0 -0
  163. package/public/assets/trash-CkO-5TRA.js.br +0 -0
  164. package/public/assets/trash-CkO-5TRA.js.gz +0 -0
@@ -27,6 +27,13 @@ export declare function ownerKeyFor(userId: number | string | undefined, claimed
27
27
  user: string;
28
28
  minted: boolean;
29
29
  };
30
+ export declare const LOGS_FORBIDDEN_MESSAGE = "Logs are only available on the relay host. Run `tapflow logs` there.";
31
+ /**
32
+ * `?lines=` for `/api/v1/logs`: an integer in [1, 500], 100 when absent or not a number. A bare
33
+ * `Number()` let `abc` through as NaN, and `slice(-NaN)` returns the whole buffer; `-5` became
34
+ * `slice(5)`, everything but the oldest five.
35
+ */
36
+ export declare function parseLogLines(raw: string | null): number;
30
37
  export declare class RelayServer {
31
38
  private readonly options;
32
39
  private httpServer;
@@ -98,6 +105,23 @@ export declare class RelayServer {
98
105
  /** Per-socket throttle state for `logInboundRejection`. A `WeakMap` so a closed socket's entry goes
99
106
  * with the socket — there is no cleanup to forget, unlike the maps keyed by session id nearby. */
100
107
  private readonly rejectionLog;
108
+ /**
109
+ * The credential each remote socket was accepted with, re-checked by `revalidateSockets` on every
110
+ * heartbeat and after every write that can take access away (`onAuthChanged`). Local sockets have
111
+ * no entry and are never re-checked: they needed no credential.
112
+ */
113
+ private readonly principals;
114
+ /** For a `first-message` socket: what its first frame may make of it. Absent means anything (local). */
115
+ private readonly firstMessageGrants;
116
+ /**
117
+ * Sockets the relay closed because their credential stopped being valid. Their frames are dropped
118
+ * from the moment of the close rather than from the peer's close reply, and an agent socket among
119
+ * them skips the reconnect grace: the agent will be refused when it comes back, so holding its
120
+ * sessions for 15 s would only postpone the same end.
121
+ */
122
+ private readonly revokedSockets;
123
+ /** Per-credential throttle for `logConnectionRejection`: agents retry forever on a refusal. */
124
+ private readonly connectionRejectLog;
101
125
  private agentHolds;
102
126
  private stopping;
103
127
  private wsExternal;
@@ -138,7 +162,34 @@ export declare class RelayServer {
138
162
  address(): string | import("net").AddressInfo | null;
139
163
  /** The tunnel listener's address, or null when this relay was given no `tunnelPort`. */
140
164
  tunnelAddress(): string | import("net").AddressInfo | null;
165
+ /**
166
+ * Migration 014 normalizes stored addresses but leaves alone any two that normalize to the same one —
167
+ * rewriting them would break `UNIQUE(email)` and the migration with it. Each still signs in by its
168
+ * exact address; name them so an Admin can remove the duplicate.
169
+ */
170
+ private warnDuplicateEmails;
171
+ /**
172
+ * An install upgrading to a relay that checks an agent token's owner stops connecting agents whose
173
+ * token belongs to a since-demoted Admin. Say how many at boot, with the fix, rather than leaving the
174
+ * operator to read 1008s off each agent.
175
+ */
176
+ private warnOrphanedAgentTokens;
141
177
  private runHeartbeat;
178
+ /**
179
+ * Injected into the handlers whose write can take access away — member update and removal, token
180
+ * revocation, a password reset or change — and called after the write commits. It carries no argument on
181
+ * purpose: the sweep re-derives every socket's standing from the database, so no handler has to know
182
+ * which sockets its change affects.
183
+ */
184
+ private readonly onAuthChanged;
185
+ private revalidateSockets;
186
+ private closeForAuth;
187
+ /**
188
+ * One line per refused credential per interval. Agents retry forever on a refusal, so logging each
189
+ * attempt would fill the 500-line buffer with one agent's reconnects. Keyed by PAT id when there is
190
+ * one and by address otherwise; the first refusal of each is always written.
191
+ */
192
+ private logConnectionRejection;
142
193
  /**
143
194
  * Has this socket answered recently enough to be treated as present?
144
195
  *
@@ -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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"}
@@ -8,9 +8,9 @@ import { WebSocketServer, WebSocket } from 'ws';
8
8
  import { SessionManager } from './SessionManager.js';
9
9
  import { directionOf, parseInbound } from '@tapflowio/protocol/validate';
10
10
  import { Router, json } from './router.js';
11
- import { requireViewAuth, requireAuth, getAuth, verifyPat } from './middleware/auth.js';
12
- import { classifyConnection } from './lib/connectionAuth.js';
13
- import { isTunnelIngress, markTunnelIngress, resolveClientAddress } from './lib/clientAddress.js';
11
+ import { requireViewAuth, requireAuth, getAuth, verifyPat, touchPat, findPat, findUser } from './middleware/auth.js';
12
+ import { AGENT_SCOPE, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, revalidatePrincipal } from './lib/connectionAuth.js';
13
+ import { isTunnelIngress, markTunnelIngress, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.js';
14
14
  import { BuildTicketStore } from './lib/buildTickets.js';
15
15
  import { resolveBuildFile } from './lib/buildFiles.js';
16
16
  import { resolveCorsHeaders } from './lib/cors.js';
@@ -78,6 +78,7 @@ function sniffImageFormat(buf) {
78
78
  return null;
79
79
  }
80
80
  import { handleVerifyReset, handleDoReset, handleSendMemberReset } from './api/passwordReset.js';
81
+ import { EMAIL_KEY_SQL } from './lib/email.js';
81
82
  import { handleListBuilds, handleGetBuild, handleUpdateBuild, handleUploadBuild, handleScheduleBuildDeletion, handleCancelBuildDeletion, purgeExpiredBuilds } from './api/builds.js';
82
83
  import { handleListApps, handleCreateApp, handleUpdateApp, handleDeleteApp } from './api/apps.js';
83
84
  import { handleListWebhooks, handleCreateWebhook, handleUpdateWebhook, handleDeleteWebhook } from './api/webhooks.js';
@@ -142,6 +143,22 @@ export function ownerKeyFor(userId, claimed) {
142
143
  }
143
144
  /** One line per rejecting socket per second, at most. See `RelayServer.logInboundRejection`. */
144
145
  const REJECT_LOG_INTERVAL_MS = 1_000;
146
+ /** One line per refused credential per minute. See `RelayServer.logConnectionRejection`. */
147
+ const CONNECTION_REJECT_LOG_INTERVAL_MS = 60_000;
148
+ export const LOGS_FORBIDDEN_MESSAGE = 'Logs are only available on the relay host. Run `tapflow logs` there.';
149
+ /**
150
+ * `?lines=` for `/api/v1/logs`: an integer in [1, 500], 100 when absent or not a number. A bare
151
+ * `Number()` let `abc` through as NaN, and `slice(-NaN)` returns the whole buffer; `-5` became
152
+ * `slice(5)`, everything but the oldest five.
153
+ */
154
+ export function parseLogLines(raw) {
155
+ if (raw === null || raw.trim() === '')
156
+ return 100;
157
+ const n = Number(raw);
158
+ if (!Number.isFinite(n))
159
+ return 100;
160
+ return Math.min(500, Math.max(1, Math.trunc(n)));
161
+ }
145
162
  /** What the door refused, as one line. The throttling that decides whether to write it is the
146
163
  * caller's, because it is per socket and this function has no state. */
147
164
  function describeRejection(failure, suppressed) {
@@ -228,6 +245,23 @@ export class RelayServer {
228
245
  /** Per-socket throttle state for `logInboundRejection`. A `WeakMap` so a closed socket's entry goes
229
246
  * with the socket — there is no cleanup to forget, unlike the maps keyed by session id nearby. */
230
247
  rejectionLog = new WeakMap();
248
+ /**
249
+ * The credential each remote socket was accepted with, re-checked by `revalidateSockets` on every
250
+ * heartbeat and after every write that can take access away (`onAuthChanged`). Local sockets have
251
+ * no entry and are never re-checked: they needed no credential.
252
+ */
253
+ principals = new WeakMap();
254
+ /** For a `first-message` socket: what its first frame may make of it. Absent means anything (local). */
255
+ firstMessageGrants = new WeakMap();
256
+ /**
257
+ * Sockets the relay closed because their credential stopped being valid. Their frames are dropped
258
+ * from the moment of the close rather than from the peer's close reply, and an agent socket among
259
+ * them skips the reconnect grace: the agent will be refused when it comes back, so holding its
260
+ * sessions for 15 s would only postpone the same end.
261
+ */
262
+ revokedSockets = new WeakSet();
263
+ /** Per-credential throttle for `logConnectionRejection`: agents retry forever on a refusal. */
264
+ connectionRejectLog = new Map();
231
265
  // Agent sockets whose sessions are being held open, and the timer that gives up on each.
232
266
  // Keyed by the dead socket, never by session id: a rebind moves sessions off that socket, so an
233
267
  // expiry that fires late has nothing left to evict. That is the invariant — releasing the hold
@@ -326,9 +360,9 @@ export class RelayServer {
326
360
  this.router.get('/api/v1/auth/me', handleMe);
327
361
  this.router.post('/api/v1/auth/login', (req, res) => handleLogin(req, res, this.options.trustedProxies ?? []));
328
362
  this.router.post('/api/v1/auth/logout', handleLogout);
329
- this.router.post('/api/v1/auth/change-password', handleChangePassword);
363
+ this.router.post('/api/v1/auth/change-password', (req, res) => handleChangePassword(req, res, this.onAuthChanged));
330
364
  this.router.get('/api/v1/auth/reset-password/verify', handleVerifyReset);
331
- this.router.post('/api/v1/auth/reset-password', handleDoReset);
365
+ this.router.post('/api/v1/auth/reset-password', (req, res) => handleDoReset(req, res, this.onAuthChanged));
332
366
  // invitations
333
367
  this.router.get('/api/v1/invitations/verify', handleVerify);
334
368
  this.router.post('/api/v1/invitations/accept', (req, res) => handleAccept(req, res, u));
@@ -362,13 +396,13 @@ export class RelayServer {
362
396
  // team
363
397
  this.router.get('/api/v1/team/members', handleListMembers);
364
398
  this.router.post('/api/v1/team/invite', (req, res) => handleInvite(req, res, this.options.tunnel));
365
- this.router.patch('/api/v1/team/members/:id', handleUpdateMember);
366
- this.router.delete('/api/v1/team/members/:id', handleDeleteMember);
399
+ this.router.patch('/api/v1/team/members/:id', (req, res, params) => handleUpdateMember(req, res, params, this.onAuthChanged));
400
+ this.router.delete('/api/v1/team/members/:id', (req, res, params) => handleDeleteMember(req, res, params, this.onAuthChanged));
367
401
  this.router.post('/api/v1/team/members/:id/send-reset', (req, res, params) => handleSendMemberReset(req, res, params, this.options.tunnel));
368
402
  // tokens
369
403
  this.router.get('/api/v1/tokens', handleListTokens);
370
404
  this.router.post('/api/v1/tokens', handleCreateToken);
371
- this.router.delete('/api/v1/tokens/:id', handleRevokeToken);
405
+ this.router.delete('/api/v1/tokens/:id', (req, res, params) => handleRevokeToken(req, res, params, this.onAuthChanged));
372
406
  // settings
373
407
  this.router.get('/api/v1/settings', handleGetSettings);
374
408
  this.router.patch('/api/v1/settings', (req, res) => handleUpdateSettings(req, res, u));
@@ -378,10 +412,16 @@ export class RelayServer {
378
412
  this.router.post('/api/v1/recordings/upload', (req, res) => handleUploadRecording(req, res, this.recordingsDir));
379
413
  this.router.get('/api/v1/recordings', (req, res) => handleListRecordings(req, res));
380
414
  this.router.get('/api/v1/recordings/:filename', (req, res) => handleDownloadRecording(req, res, this.recordingsDir));
381
- // logs
415
+ // logs — the relay host only. The buffer holds the addresses of refused connections, nothing
416
+ // remote reads it (`tapflow logs` is its only consumer), and every line is also in the relay's own
417
+ // output. Same locality rule and proxy list as `auth/init` and the WebSocket handshake, so the tunnel
418
+ // listener is remote here too.
382
419
  this.router.get('/api/v1/logs', (req, res) => {
420
+ if (!resolveRequestClient(req, this.options.trustedProxies ?? []).isLocal) {
421
+ return json(res, 403, { error: LOGS_FORBIDDEN_MESSAGE });
422
+ }
383
423
  const url = new URL(req.url ?? '/', `http://localhost`);
384
- const lines = Math.min(Number(url.searchParams.get('lines') ?? 100), 500);
424
+ const lines = parseLogLines(url.searchParams.get('lines'));
385
425
  res.writeHead(200, { 'Content-Type': 'application/json' });
386
426
  res.end(JSON.stringify(this.logBuffer.slice(-lines)));
387
427
  });
@@ -420,6 +460,8 @@ export class RelayServer {
420
460
  // Creates this install's JWT secret if it has none. Here rather than at import, so commands
421
461
  // that never run a relay stop leaving one behind — and a write failure is still a boot failure.
422
462
  getJwtSecret();
463
+ this.warnOrphanedAgentTokens();
464
+ this.warnDuplicateEmails();
423
465
  purgeExpiredRecordings(this.recordingsDir);
424
466
  this.purgeRecordingsTimer = setInterval(() => purgeExpiredRecordings(this.recordingsDir), 24 * 60 * 60 * 1000);
425
467
  this.purgeRecordingsTimer.unref();
@@ -529,8 +571,42 @@ export class RelayServer {
529
571
  tunnelAddress() {
530
572
  return this.tunnelServer?.address() ?? null;
531
573
  }
574
+ /**
575
+ * Migration 014 normalizes stored addresses but leaves alone any two that normalize to the same one —
576
+ * rewriting them would break `UNIQUE(email)` and the migration with it. Each still signs in by its
577
+ * exact address; name them so an Admin can remove the duplicate.
578
+ */
579
+ warnDuplicateEmails() {
580
+ const groups = getDb().prepare(`
581
+ SELECT ${EMAIL_KEY_SQL} AS email, GROUP_CONCAT(id) AS ids FROM users
582
+ GROUP BY ${EMAIL_KEY_SQL} HAVING COUNT(*) > 1
583
+ `).all();
584
+ for (const g of groups) {
585
+ logger.warn(`Accounts ${g.ids} differ only in letter case or spaces (${g.email}). Each signs in only with its exact address; remove the duplicate in Dashboard → Settings → Team.`);
586
+ }
587
+ }
588
+ /**
589
+ * An install upgrading to a relay that checks an agent token's owner stops connecting agents whose
590
+ * token belongs to a since-demoted Admin. Say how many at boot, with the fix, rather than leaving the
591
+ * operator to read 1008s off each agent.
592
+ */
593
+ warnOrphanedAgentTokens() {
594
+ const row = getDb().prepare(`
595
+ SELECT COUNT(*) AS n FROM personal_access_tokens pat JOIN users u ON u.id = pat.user_id
596
+ WHERE u.role <> 'Admin'
597
+ AND (',' || REPLACE(pat.scope, ' ', '') || ',') LIKE '%,${AGENT_SCOPE},%'
598
+ AND (pat.expires_at IS NULL OR datetime(pat.expires_at) > datetime('now'))
599
+ `).get();
600
+ if (row.n > 0) {
601
+ logger.warn(`${row.n} agent token(s) belong to a member who is no longer an Admin and will be refused. An Admin must issue a new agent token (Dashboard → Settings → Tokens) for each agent that uses one.`);
602
+ }
603
+ }
532
604
  // Terminate sockets that missed the previous pong; ping the rest. Covers all roles via wss.clients.
605
+ // Every socket's credential is re-checked on the same sweep: this is the guarantee that a removed
606
+ // member, a revoked or expired token, or an expired session cookie loses an open socket within one
607
+ // interval, whatever path the change took. `onAuthChanged` is the same check run early.
533
608
  runHeartbeat(clients = this.wss.clients) {
609
+ this.revalidateSockets(clients);
534
610
  for (const ws of clients) {
535
611
  if (!this.isAlive(ws)) {
536
612
  ws.terminate();
@@ -540,6 +616,77 @@ export class RelayServer {
540
616
  ws.ping();
541
617
  }
542
618
  }
619
+ /**
620
+ * Injected into the handlers whose write can take access away — member update and removal, token
621
+ * revocation, a password reset or change — and called after the write commits. It carries no argument on
622
+ * purpose: the sweep re-derives every socket's standing from the database, so no handler has to know
623
+ * which sockets its change affects.
624
+ */
625
+ onAuthChanged = () => {
626
+ this.revalidateSockets(this.wss.clients);
627
+ };
628
+ revalidateSockets(clients) {
629
+ const now = Math.floor(Date.now() / 1000);
630
+ for (const ws of clients) {
631
+ const principal = this.principals.get(ws);
632
+ if (!principal || this.revokedSockets.has(ws) || ws.readyState !== WebSocket.OPEN)
633
+ continue;
634
+ let reason;
635
+ try {
636
+ const pat = principal.via === 'pat' ? findPat(principal.patId) : null;
637
+ const role = this.wsRoles.get(ws);
638
+ reason = revalidatePrincipal(principal, role, {
639
+ userPasswordVersion: principal.via === 'cookie' ? findUser(principal.userId)?.pwv ?? null : null,
640
+ pat,
641
+ }, now);
642
+ // A socket that has not introduced itself yet takes what a fresh handshake would give it now.
643
+ if (reason === null && role === undefined && pat) {
644
+ const again = classifyConnection({ isLocal: false, hasCookieAuth: false, patScopes: pat.scopes, patOwnerRole: pat.ownerRole });
645
+ if (again.action === 'accept') {
646
+ this.firstMessageGrants.set(ws, again.role === 'browser'
647
+ ? { mayBrowse: true, mayRegisterAgent: false }
648
+ : { mayBrowse: again.mayBrowse, mayRegisterAgent: again.mayRegisterAgent });
649
+ }
650
+ }
651
+ }
652
+ catch (err) {
653
+ // A database fault is not a revocation: closing every socket on a transient `SQLITE_BUSY` would
654
+ // disconnect the whole team. The next sweep asks again.
655
+ logger.error('socket re-validation failed:', err);
656
+ return;
657
+ }
658
+ if (reason !== null)
659
+ this.closeForAuth(ws, reason);
660
+ }
661
+ }
662
+ closeForAuth(ws, reason) {
663
+ this.revokedSockets.add(ws);
664
+ this.pushLog(`WS connection closed — ${reason}`);
665
+ ws.close(1008, reason);
666
+ }
667
+ /**
668
+ * One line per refused credential per interval. Agents retry forever on a refusal, so logging each
669
+ * attempt would fill the 500-line buffer with one agent's reconnects. Keyed by PAT id when there is
670
+ * one and by address otherwise; the first refusal of each is always written.
671
+ */
672
+ logConnectionRejection(addr, reason, patId) {
673
+ const key = patId !== undefined ? `pat:${patId}` : `addr:${addr}`;
674
+ const now = Date.now();
675
+ const state = this.connectionRejectLog.get(key);
676
+ if (state && now - state.at < CONNECTION_REJECT_LOG_INTERVAL_MS) {
677
+ state.suppressed++;
678
+ return;
679
+ }
680
+ if (this.connectionRejectLog.size > 1000) {
681
+ for (const [k, v] of this.connectionRejectLog) {
682
+ if (now - v.at >= CONNECTION_REJECT_LOG_INTERVAL_MS)
683
+ this.connectionRejectLog.delete(k);
684
+ }
685
+ }
686
+ const also = state && state.suppressed > 0 ? ` (+${state.suppressed} more in the last minute)` : '';
687
+ this.connectionRejectLog.set(key, { at: now, suppressed: 0 });
688
+ this.pushLog(`WS connection rejected from ${addr} — ${reason}${also}`);
689
+ }
543
690
  /**
544
691
  * Has this socket answered recently enough to be treated as present?
545
692
  *
@@ -700,17 +847,50 @@ export class RelayServer {
700
847
  trustedProxies: this.options.trustedProxies ?? [],
701
848
  viaTunnel,
702
849
  });
703
- const hasCookieAuth = getAuth(request) !== null;
704
- // DB lookup — only when the connection can't be classified without it (remote, no cookie).
705
- const pat = !isLocal && !hasCookieAuth ? verifyPat(request) : null;
706
- const decision = classifyConnection({
707
- isLocal,
708
- hasCookieAuth,
709
- patScopes: pat ? pat.scope.split(',').map((s) => s.trim()) : null,
710
- });
711
- if (decision.action === 'reject') {
712
- this.pushLog(`WS connection rejected from ${addr} — no credentials (agents: PAT with 'agent' scope via --token)`);
713
- ws.close(1008, decision.reason);
850
+ // **Everything that reads the database is inside this try.** `getAuth` and `verifyPat` both query,
851
+ // and this handler runs synchronously in the `connection` event: a throw here is uncaught and takes
852
+ // the relay process down with every session on it. A fault closes this one socket instead (1011),
853
+ // and is never read as "no credentials".
854
+ let decision;
855
+ let userId;
856
+ try {
857
+ // Resolved once: `hasCookieAuth`, the owner key and the principal all read this one answer. Two
858
+ // calls were two queries, with a window where they could disagree.
859
+ const cookie = getAuth(request);
860
+ // DB lookup — only when the connection can't be classified without it (remote, no cookie).
861
+ const pat = !isLocal && !cookie ? verifyPat(request) : null;
862
+ decision = classifyConnection({
863
+ isLocal,
864
+ hasCookieAuth: cookie !== null,
865
+ patScopes: pat?.scopes ?? null,
866
+ patOwnerRole: pat?.ownerRole ?? null,
867
+ });
868
+ if (decision.action === 'reject') {
869
+ this.logConnectionRejection(addr, decision.reason, pat?.patId);
870
+ ws.close(1008, decision.reason);
871
+ return;
872
+ }
873
+ // Only an accepted token was used; a refused one keeps its "last used".
874
+ if (pat)
875
+ touchPat(pat.patId);
876
+ userId = cookie?.userId ?? pat?.userId;
877
+ if (!isLocal) {
878
+ if (cookie)
879
+ this.principals.set(ws, { via: 'cookie', userId: cookie.userId, jwtExp: cookie.exp, pwv: cookie.pwv });
880
+ else if (pat)
881
+ this.principals.set(ws, { via: 'pat', userId: pat.userId, patId: pat.patId });
882
+ }
883
+ if (decision.role === 'first-message') {
884
+ this.firstMessageGrants.set(ws, { mayBrowse: decision.mayBrowse, mayRegisterAgent: decision.mayRegisterAgent });
885
+ }
886
+ else if (pat?.scopes.includes(AGENT_SCOPE)) {
887
+ // A `view,agent` token whose owner is no longer an Admin: browsing only, and a handshake is told why.
888
+ this.firstMessageGrants.set(ws, { mayBrowse: true, mayRegisterAgent: false });
889
+ }
890
+ }
891
+ catch (err) {
892
+ logger.error(`WebSocket handshake failed for ${addr}:`, err);
893
+ ws.close(1011, 'Internal error');
714
894
  return;
715
895
  }
716
896
  this.wsExternal.set(ws, isExternalAddress(addr));
@@ -722,8 +902,8 @@ export class RelayServer {
722
902
  // so every agent, `mcp-server` and `flow-runner` connection would have been `anon:` — and the whole
723
903
  // point of pairing the claim with a user is that a leaked client id is useless to anyone else. Two
724
904
  // processes on the same PAT are the same principal, which is right; two on different PATs are not,
725
- // which is what this restores. `pat` is already resolved above and was being discarded.
726
- this.ownerKey.set(ws, ownerKeyFor(getAuth(request)?.userId ?? pat?.userId, claimed));
905
+ // which is what this restores.
906
+ this.ownerKey.set(ws, ownerKeyFor(userId, claimed));
727
907
  // Heartbeat liveness: a fresh socket counts as having just answered, and each pong renews it.
728
908
  this.lastPongAt.set(ws, Date.now());
729
909
  ws.on('pong', () => this.lastPongAt.set(ws, Date.now()));
@@ -731,6 +911,10 @@ export class RelayServer {
731
911
  this.wsRoles.set(ws, 'browser');
732
912
  // 'first-message' → role is determined by the first message (agent:register / stream:register)
733
913
  ws.on('message', (data, isBinary) => {
914
+ // Closed for a credential that stopped being valid: nothing it sends after that counts, even
915
+ // before its close reply arrives.
916
+ if (this.revokedSockets.has(ws))
917
+ return;
734
918
  if (isBinary) {
735
919
  // Binary frames arrive on the dedicated stream WS, route to the session's browser
736
920
  const session = this.sessions.getByStreamSocket(ws);
@@ -804,9 +988,9 @@ export class RelayServer {
804
988
  return;
805
989
  if (!inbound.ok) {
806
990
  // **After the role gate, never before it**, so a browser spoofing an agent-only type gets 1008
807
- // and no reply. That is the whole of the claim: an agent- or stream-role socket sending a
808
- // malformed browser request passes the gate and *is* answered, because the gate only refuses a
809
- // `browser` role sending a non-browser type. Harmless, and worth stating rather than implying
991
+ // and no reply. That is the whole of the claim: an agent- or stream-role socket whose token also
992
+ // carries `view` (or a local one) sending a malformed browser request passes the gate and *is*
993
+ // answered — without `view` it was already closed above. Harmless, and worth stating rather than implying
810
994
  // otherwise — such a socket already gets an answer on the well-formed path (`refuseInput` tells
811
995
  // it `not-session-owner`), and this reply says strictly less than that one.
812
996
  if (inbound.reason === 'bad-payload')
@@ -888,22 +1072,40 @@ export class RelayServer {
888
1072
  // inverts the check below: a socket whose first frame is `screenshot:done` would be handed the
889
1073
  // `agent` role and then waved through the gate that exists to refuse exactly that.
890
1074
  const handshake = type === 'agent:register' || type === 'stream:register';
1075
+ // What the credentials allow the first frame to make of this socket (`classifyConnection`). No entry
1076
+ // is a socket that may become anything: a local one.
1077
+ const grants = this.firstMessageGrants.get(ws);
891
1078
  if (!this.wsRoles.has(ws)) {
892
1079
  // A handshake that did not parse confers nothing. Returning here rather than falling through to
893
1080
  // `browser` is what keeps an agent whose register is malformed from being closed with
894
1081
  // `Forbidden` — the caller has already logged it, and the next frame still gets to introduce it.
895
1082
  if (handshake && !inbound.ok)
896
1083
  return false;
1084
+ if (handshake && grants && !grants.mayRegisterAgent) {
1085
+ ws.close(1008, WS_AGENT_OWNER_REASON);
1086
+ return false;
1087
+ }
897
1088
  if (type === 'agent:register')
898
1089
  this.wsRoles.set(ws, 'agent');
899
1090
  else if (type === 'stream:register')
900
1091
  this.wsRoles.set(ws, 'stream');
901
- // Local connection whose first message is not an agent/stream handshake — treat it as a browser
902
- // (e.g. dashboard opened on the same machine). This fallback is what puts such a socket under
903
- // the gate below.
1092
+ // A first message that is not an agent/stream handshake makes the socket a browser (e.g. the
1093
+ // dashboard opened on the same machine) — but only when its credentials could have opened a
1094
+ // browser socket directly. An `agent`-only token used to land here as a browser too.
1095
+ else if (grants && !grants.mayBrowse) {
1096
+ ws.close(1008, WS_SCOPE_REASON);
1097
+ return false;
1098
+ }
1099
+ // This fallback is what puts such a socket under the gate below.
904
1100
  else
905
1101
  this.wsRoles.set(ws, 'browser');
906
1102
  }
1103
+ // A `view,agent` token whose owner lost Admin was accepted as a browser; its handshake is told why
1104
+ // it cannot register rather than a bare `Forbidden`.
1105
+ if (handshake && this.wsRoles.get(ws) === 'browser' && grants && !grants.mayRegisterAgent) {
1106
+ ws.close(1008, WS_AGENT_OWNER_REASON);
1107
+ return false;
1108
+ }
907
1109
  // Browser sockets must not spoof agent control messages.
908
1110
  //
909
1111
  // This was a hand-copied array of 29 literals with two type assertions holding it against the
@@ -913,6 +1115,17 @@ export class RelayServer {
913
1115
  ws.close(1008, 'Forbidden');
914
1116
  return false;
915
1117
  }
1118
+ // The other direction of the same rule. An agent or stream socket on a token without `view` may
1119
+ // not act as a browser after its handshake either — `session:start`, `input:*`, `device:*` — or an
1120
+ // `agent`-only token would drive devices just by registering first. No agent needs this: their
1121
+ // text frames go through `sendMsg`/`sendOn`, typed `AgentToRelay | AgentToBrowser`, and the stream
1122
+ // socket sends only `stream:register`; none of those types is browser-direction (checked against
1123
+ // `BrowserToRelay` when this was added). Local agents carry no grants and are not gated.
1124
+ const role = this.wsRoles.get(ws);
1125
+ if ((role === 'agent' || role === 'stream') && grants && !grants.mayBrowse && directionOf(type) === 'browser') {
1126
+ ws.close(1008, WS_SCOPE_REASON);
1127
+ return false;
1128
+ }
916
1129
  return true;
917
1130
  }
918
1131
  /**
@@ -1453,9 +1666,17 @@ export class RelayServer {
1453
1666
  */
1454
1667
  holdAgentSocket(ws) {
1455
1668
  // Shutting down: nothing is coming back, and arming a timer here would leave it running after
1456
- // `stop()` resolved.
1669
+ // `stop()` resolved. Closed by the relay for a credential that is no longer valid: the agent will be
1670
+ // refused when it returns, so a hold would only postpone the same end.
1457
1671
  if (this.stopping)
1458
1672
  return this.evictAgentSocket(ws);
1673
+ if (this.revokedSockets.has(ws)) {
1674
+ const evicted = this.evictAgentSocket(ws);
1675
+ // `evictAgentSocket` keeps the resource entry when there were no sessions to end.
1676
+ if (!evicted)
1677
+ this.sessions.removeResources(ws);
1678
+ return evicted;
1679
+ }
1459
1680
  const sessions = this.sessions.getAllByAgentSocket(ws);
1460
1681
  if (sessions.length === 0) {
1461
1682
  // No sessions to hold, but the socket may still own a resource entry — `evictAgentSocket`
@@ -2340,8 +2561,18 @@ export class RelayServer {
2340
2561
  }
2341
2562
  }
2342
2563
  if (url.startsWith('/uploads/')) {
2343
- if (!requireViewAuth(req, res))
2564
+ // Outside the router, so outside its catch: the auth check reads the database, and a throw here
2565
+ // would be an unhandled rejection that ends the process.
2566
+ try {
2567
+ if (!requireViewAuth(req, res))
2568
+ return;
2569
+ }
2570
+ catch (err) {
2571
+ logger.error('upload auth failed:', err);
2572
+ if (!res.headersSent)
2573
+ json(res, 500, { error: 'Internal server error' });
2344
2574
  return;
2575
+ }
2345
2576
  this.serveUpload(req, res);
2346
2577
  return;
2347
2578
  }