@tapflowio/relay 0.23.0 → 0.25.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 (204) hide show
  1. package/dist/RelayServer.d.ts +45 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +263 -38
  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 +13 -14
  11. package/dist/api/auth.js.map +1 -1
  12. package/dist/api/builds.d.ts +1 -1
  13. package/dist/api/builds.d.ts.map +1 -1
  14. package/dist/api/builds.js +15 -7
  15. package/dist/api/builds.js.map +1 -1
  16. package/dist/api/comments.d.ts.map +1 -1
  17. package/dist/api/comments.js +41 -15
  18. package/dist/api/comments.js.map +1 -1
  19. package/dist/api/invitations.d.ts +1 -1
  20. package/dist/api/invitations.d.ts.map +1 -1
  21. package/dist/api/invitations.js +7 -3
  22. package/dist/api/invitations.js.map +1 -1
  23. package/dist/api/passwordReset.js +2 -2
  24. package/dist/api/team.d.ts +2 -2
  25. package/dist/api/team.d.ts.map +1 -1
  26. package/dist/api/team.js +6 -2
  27. package/dist/api/team.js.map +1 -1
  28. package/dist/api/tokens.d.ts +1 -1
  29. package/dist/api/tokens.d.ts.map +1 -1
  30. package/dist/api/tokens.js +26 -7
  31. package/dist/api/tokens.js.map +1 -1
  32. package/dist/api/webhooks.d.ts.map +1 -1
  33. package/dist/api/webhooks.js +11 -5
  34. package/dist/api/webhooks.js.map +1 -1
  35. package/dist/index.d.ts +3 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/lib/buildFiles.d.ts +23 -0
  40. package/dist/lib/buildFiles.d.ts.map +1 -0
  41. package/dist/lib/buildFiles.js +39 -0
  42. package/dist/lib/buildFiles.js.map +1 -0
  43. package/dist/lib/clientAddress.d.ts +7 -0
  44. package/dist/lib/clientAddress.d.ts.map +1 -1
  45. package/dist/lib/clientAddress.js +15 -0
  46. package/dist/lib/clientAddress.js.map +1 -1
  47. package/dist/lib/config.d.ts +26 -1
  48. package/dist/lib/config.d.ts.map +1 -1
  49. package/dist/lib/config.js +81 -17
  50. package/dist/lib/config.js.map +1 -1
  51. package/dist/lib/connectionAuth.d.ts +57 -2
  52. package/dist/lib/connectionAuth.d.ts.map +1 -1
  53. package/dist/lib/connectionAuth.js +65 -6
  54. package/dist/lib/connectionAuth.js.map +1 -1
  55. package/dist/lib/dataDir.d.ts +48 -1
  56. package/dist/lib/dataDir.d.ts.map +1 -1
  57. package/dist/lib/dataDir.js +108 -12
  58. package/dist/lib/dataDir.js.map +1 -1
  59. package/dist/middleware/auth.d.ts +44 -4
  60. package/dist/middleware/auth.d.ts.map +1 -1
  61. package/dist/middleware/auth.js +102 -30
  62. package/dist/middleware/auth.js.map +1 -1
  63. package/dist/server.js +2 -1
  64. package/dist/server.js.map +1 -1
  65. package/package.json +4 -4
  66. package/public/assets/AppCenter-D_1rFI40.js +26 -0
  67. package/public/assets/AppCenter-D_1rFI40.js.br +0 -0
  68. package/public/assets/AppCenter-D_1rFI40.js.gz +0 -0
  69. package/public/assets/Default-DF_X2_3I.js +1 -0
  70. package/public/assets/Default-DF_X2_3I.js.br +0 -0
  71. package/public/assets/Default-DF_X2_3I.js.gz +0 -0
  72. package/public/assets/Invite-DETTWdYp.js +1 -0
  73. package/public/assets/Invite-DETTWdYp.js.br +0 -0
  74. package/public/assets/Invite-DETTWdYp.js.gz +0 -0
  75. package/public/assets/MacResources--1Enisxs.js +1 -0
  76. package/public/assets/MacResources--1Enisxs.js.br +0 -0
  77. package/public/assets/MacResources--1Enisxs.js.gz +0 -0
  78. package/public/assets/NotFound-wHOnT2Fl.js +1 -0
  79. package/public/assets/NotFound-wHOnT2Fl.js.br +0 -0
  80. package/public/assets/NotFound-wHOnT2Fl.js.gz +0 -0
  81. package/public/assets/QASession-BjoD9Toj.js +150 -0
  82. package/public/assets/QASession-BjoD9Toj.js.br +0 -0
  83. package/public/assets/QASession-BjoD9Toj.js.gz +0 -0
  84. package/public/assets/ResetPassword-B9miyXTP.js +1 -0
  85. package/public/assets/ResetPassword-B9miyXTP.js.br +0 -0
  86. package/public/assets/ResetPassword-B9miyXTP.js.gz +0 -0
  87. package/public/assets/Setup-CywZCqHU.js +1 -0
  88. package/public/assets/Setup-CywZCqHU.js.br +0 -0
  89. package/public/assets/Setup-CywZCqHU.js.gz +0 -0
  90. package/public/assets/Team-CxdX_9mY.js +6 -0
  91. package/public/assets/Team-CxdX_9mY.js.br +0 -0
  92. package/public/assets/Team-CxdX_9mY.js.gz +0 -0
  93. package/public/assets/Tokens-BQnnawvG.js +1 -0
  94. package/public/assets/Tokens-BQnnawvG.js.br +0 -0
  95. package/public/assets/Tokens-BQnnawvG.js.gz +0 -0
  96. package/public/assets/alert-dialog-CuPdBkxF.js +1 -0
  97. package/public/assets/alert-dialog-CuPdBkxF.js.br +0 -0
  98. package/public/assets/alert-dialog-CuPdBkxF.js.gz +0 -0
  99. package/public/assets/badge-CSVsKOK_.js +1 -0
  100. package/public/assets/badge-CSVsKOK_.js.br +0 -0
  101. package/public/assets/badge-CSVsKOK_.js.gz +0 -0
  102. package/public/assets/build-format-l9BrlbZl.js +11 -0
  103. package/public/assets/build-format-l9BrlbZl.js.br +0 -0
  104. package/public/assets/build-format-l9BrlbZl.js.gz +0 -0
  105. package/public/assets/dialog-B6jt7-2f.js +11 -0
  106. package/public/assets/dialog-B6jt7-2f.js.br +0 -0
  107. package/public/assets/dialog-B6jt7-2f.js.gz +0 -0
  108. package/public/assets/index-Cm17I31a.js +218 -0
  109. package/public/assets/index-Cm17I31a.js.br +0 -0
  110. package/public/assets/index-Cm17I31a.js.gz +0 -0
  111. package/public/assets/index-D_P99bPX.css +1 -0
  112. package/public/assets/index-D_P99bPX.css.br +0 -0
  113. package/public/assets/index-D_P99bPX.css.gz +0 -0
  114. package/public/assets/list-view-Bp6pvsde.js +1 -0
  115. package/public/assets/list-view-Bp6pvsde.js.br +0 -0
  116. package/public/assets/list-view-Bp6pvsde.js.gz +0 -0
  117. package/public/assets/{pencil-CPaoEKFm.js → pencil-g-WiK1F2.js} +2 -2
  118. package/public/assets/pencil-g-WiK1F2.js.br +0 -0
  119. package/public/assets/pencil-g-WiK1F2.js.gz +0 -0
  120. package/public/assets/resource-health-BbeyA8an.js +1 -0
  121. package/public/assets/resource-health-BbeyA8an.js.br +0 -0
  122. package/public/assets/resource-health-BbeyA8an.js.gz +0 -0
  123. package/public/assets/{trash-CD9Y9G-i.js → trash-DHPg15P4.js} +3 -3
  124. package/public/assets/trash-DHPg15P4.js.br +0 -0
  125. package/public/assets/trash-DHPg15P4.js.gz +0 -0
  126. package/public/assets/useFocusAfterSwap-BY3N9f9v.js +1 -0
  127. package/public/assets/useFocusAfterSwap-BY3N9f9v.js.br +0 -0
  128. package/public/assets/useFocusAfterSwap-BY3N9f9v.js.gz +0 -0
  129. package/public/assets/{vendor-charts-YPhPkjLw.js → vendor-charts-BykzUV2D.js} +1 -1
  130. package/public/assets/vendor-charts-BykzUV2D.js.br +0 -0
  131. package/public/assets/vendor-charts-BykzUV2D.js.gz +0 -0
  132. package/public/assets/{vendor-react-C5H_1fOn.js → vendor-react-BlPSzrEe.js} +18 -10
  133. package/public/assets/vendor-react-BlPSzrEe.js.br +0 -0
  134. package/public/assets/vendor-react-BlPSzrEe.js.gz +0 -0
  135. package/public/assets/{vendor-router-CnmrRwJF.js → vendor-router-B_lVHrKl.js} +3 -3
  136. package/public/assets/vendor-router-B_lVHrKl.js.br +0 -0
  137. package/public/assets/vendor-router-B_lVHrKl.js.gz +0 -0
  138. package/public/index.html +4 -4
  139. package/public/index.html.br +0 -0
  140. package/public/index.html.gz +0 -0
  141. package/public/assets/AppCenter-OTDuqwSf.js +0 -21
  142. package/public/assets/AppCenter-OTDuqwSf.js.br +0 -0
  143. package/public/assets/AppCenter-OTDuqwSf.js.gz +0 -0
  144. package/public/assets/Default-D9GLpWlN.js +0 -1
  145. package/public/assets/Default-D9GLpWlN.js.br +0 -0
  146. package/public/assets/Default-D9GLpWlN.js.gz +0 -0
  147. package/public/assets/Invite-Bhiam8En.js +0 -1
  148. package/public/assets/Invite-Bhiam8En.js.br +0 -0
  149. package/public/assets/Invite-Bhiam8En.js.gz +0 -0
  150. package/public/assets/MacResources-DObzVJxQ.js +0 -3
  151. package/public/assets/MacResources-DObzVJxQ.js.br +0 -0
  152. package/public/assets/MacResources-DObzVJxQ.js.gz +0 -0
  153. package/public/assets/NotFound-Dz_k_Mzf.js +0 -1
  154. package/public/assets/NotFound-Dz_k_Mzf.js.br +0 -0
  155. package/public/assets/NotFound-Dz_k_Mzf.js.gz +0 -0
  156. package/public/assets/QASession-2crubOLY.js +0 -150
  157. package/public/assets/QASession-2crubOLY.js.br +0 -0
  158. package/public/assets/QASession-2crubOLY.js.gz +0 -0
  159. package/public/assets/ResetPassword-EUm12ojo.js +0 -1
  160. package/public/assets/ResetPassword-EUm12ojo.js.br +0 -0
  161. package/public/assets/ResetPassword-EUm12ojo.js.gz +0 -0
  162. package/public/assets/Setup-Fc_0RW0a.js +0 -1
  163. package/public/assets/Setup-Fc_0RW0a.js.br +0 -0
  164. package/public/assets/Setup-Fc_0RW0a.js.gz +0 -0
  165. package/public/assets/Team-Ac5aOGgx.js +0 -6
  166. package/public/assets/Team-Ac5aOGgx.js.br +0 -0
  167. package/public/assets/Team-Ac5aOGgx.js.gz +0 -0
  168. package/public/assets/Tokens-pEnCy-7l.js +0 -1
  169. package/public/assets/Tokens-pEnCy-7l.js.br +0 -0
  170. package/public/assets/Tokens-pEnCy-7l.js.gz +0 -0
  171. package/public/assets/alert-dialog-DhOTocMh.js +0 -1
  172. package/public/assets/alert-dialog-DhOTocMh.js.br +0 -0
  173. package/public/assets/alert-dialog-DhOTocMh.js.gz +0 -0
  174. package/public/assets/badge-B4gbViMs.js +0 -1
  175. package/public/assets/badge-B4gbViMs.js.br +0 -0
  176. package/public/assets/badge-B4gbViMs.js.gz +0 -0
  177. package/public/assets/build-format-DKGajT96.js +0 -11
  178. package/public/assets/build-format-DKGajT96.js.br +0 -0
  179. package/public/assets/build-format-DKGajT96.js.gz +0 -0
  180. package/public/assets/dialog-DEL1xxKJ.js +0 -11
  181. package/public/assets/dialog-DEL1xxKJ.js.br +0 -0
  182. package/public/assets/dialog-DEL1xxKJ.js.gz +0 -0
  183. package/public/assets/index-Bxs7CdEi.css +0 -1
  184. package/public/assets/index-Bxs7CdEi.css.br +0 -0
  185. package/public/assets/index-Bxs7CdEi.css.gz +0 -0
  186. package/public/assets/index-COIlCLCW.js +0 -218
  187. package/public/assets/index-COIlCLCW.js.br +0 -0
  188. package/public/assets/index-COIlCLCW.js.gz +0 -0
  189. package/public/assets/pencil-CPaoEKFm.js.br +0 -0
  190. package/public/assets/pencil-CPaoEKFm.js.gz +0 -0
  191. package/public/assets/resource-health-Ca4-5Rde.js +0 -1
  192. package/public/assets/resource-health-Ca4-5Rde.js.br +0 -0
  193. package/public/assets/resource-health-Ca4-5Rde.js.gz +0 -0
  194. package/public/assets/table-CSeGVnii.js +0 -1
  195. package/public/assets/table-CSeGVnii.js.br +0 -0
  196. package/public/assets/table-CSeGVnii.js.gz +0 -0
  197. package/public/assets/trash-CD9Y9G-i.js.br +0 -0
  198. package/public/assets/trash-CD9Y9G-i.js.gz +0 -0
  199. package/public/assets/vendor-charts-YPhPkjLw.js.br +0 -0
  200. package/public/assets/vendor-charts-YPhPkjLw.js.gz +0 -0
  201. package/public/assets/vendor-react-C5H_1fOn.js.br +0 -0
  202. package/public/assets/vendor-react-C5H_1fOn.js.gz +0 -0
  203. package/public/assets/vendor-router-CnmrRwJF.js.br +0 -0
  204. package/public/assets/vendor-router-CnmrRwJF.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,28 @@ 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
+ * An install upgrading to a relay that checks an agent token's owner stops connecting agents whose
167
+ * token belongs to a since-demoted Admin. Say how many at boot, with the fix, rather than leaving the
168
+ * operator to read 1008s off each agent.
169
+ */
170
+ private warnOrphanedAgentTokens;
141
171
  private runHeartbeat;
172
+ /**
173
+ * Injected into the handlers whose write can take access away — member update and removal, token
174
+ * revocation, invitation accept — and called after the write commits. It carries no argument on
175
+ * purpose: the sweep re-derives every socket's standing from the database, so no handler has to know
176
+ * which sockets its change affects.
177
+ */
178
+ private readonly onAuthChanged;
179
+ private revalidateSockets;
180
+ private closeForAuth;
181
+ /**
182
+ * One line per refused credential per interval. Agents retry forever on a refusal, so logging each
183
+ * attempt would fill the 500-line buffer with one agent's reconnects. Keyed by PAT id when there is
184
+ * one and by address otherwise; the first refusal of each is always written.
185
+ */
186
+ private logConnectionRejection;
142
187
  /**
143
188
  * Has this socket answered recently enough to be treated as present?
144
189
  *
@@ -1 +1 @@
1
- {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"AAsBA,OAAO,EAA4D,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAwBjH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAQvD;AAGD;;gFAEgF;AAChF,eAAO,MAAM,uBAAuB,MAAM,CAAA;AAC1C;;sGAEsG;AACtG,eAAO,MAAM,iCAAiC,MAAM,CAAA;AAqEpD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,EACnC,OAAO,EAAE,MAAM,GAAG,IAAI,GACrB;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAUhD;AA8BD,qBAAa,WAAW;IA6GV,OAAO,CAAC,QAAQ,CAAC,OAAO;IA5GpC,OAAO,CAAC,UAAU,CAA4B;IAC9C;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,YAAY,CAA0C;IAC9D,OAAO,CAAC,GAAG,CAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAgB;IAChC,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,eAAe,CAAsD;IAC7E,OAAO,CAAC,SAAS,CAAe;IAChC,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,oBAAoB,CAA8C;IAC1E,OAAO,CAAC,sBAAsB,CAA8C;IAC5E,OAAO,CAAC,gBAAgB,CAA8C;IACtE,OAAO,CAAC,mBAAmB,CAA8C;IACzE,OAAO,CAAC,cAAc,CAA8C;IACpE;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,UAAU,CAAmC;IACrD;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,QAAQ,CAA2E;IAC3F,OAAO,CAAC,YAAY,CAAgC;IAEpD,OAAO,CAAC,iBAAiB,CAAgC;IAEzD,OAAO,CAAC,QAAQ,CAAyC;IAEzD,OAAO,CAAC,aAAa,CAAgC;IACrD;gCAC4B;IAC5B,OAAO,CAAC,sBAAsB,CAAuC;IACrE,OAAO,CAAC,OAAO,CAAuD;IACtE;uGACmG;IACnG,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA+D;IAM5F,OAAO,CAAC,UAAU,CAAsD;IAIxE,OAAO,CAAC,QAAQ,CAAQ;IAGxB,OAAO,CAAC,UAAU,CAAgC;IAClD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAQ;IAC1C,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAQ;IAC5C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAa;IAEzC,OAAO,CAAC,oBAAoB,CAAQ;IACpC,mGAAmG;IACnG,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAoB;IAC1D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAyB;IACtD,OAAO,CAAC,kBAAkB,CAKtB;IACJ,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAQ;IACxC,OAAO,CAAC,cAAc,CAKlB;gBAEyB,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,GAAG,CAAC,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,aAAa,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE;IAkEzW,OAAO,CAAC,cAAc;IA2GtB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAO1B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAoCtB,OAAO,CAAC,YAAY;IAyBpB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAgCrB,OAAO;IAIP,wFAAwF;IACxF,aAAa;IAKb,OAAO,CAAC,YAAY;IAOpB;;;;;;;;OAQG;IACH,OAAO,CAAC,OAAO;IAYf,gBAAgB,CAAC,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAQ/D;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,kBAAkB;IAS1B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,YAAY;IAiBpB;;;;;;;;;;;;;;;;;;OAkBG;IACH,OAAO,CAAC,qBAAqB;IAuB7B,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,sBAAsB;IAe9B,OAAO,CAAC,gBAAgB;IAuKxB;;;;;;;;;;OAUG;IACH;;;;;OAKG;IACH,OAAO,CAAC,UAAU;IAwClB;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,OAAO,CAAC,mBAAmB;IAgB3B,OAAO,CAAC,KAAK;IAyZb,OAAO,CAAC,oBAAoB;IAc5B;;;;;;;;OAQG;IACH,OAAO,CAAC,MAAM;IAKd;;;;;OAKG;IACH,OAAO,CAAC,aAAa;IAWrB;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,eAAe;IAkCvB,+EAA+E;IAC/E,OAAO,CAAC,WAAW;IAOnB;;;;OAIG;IACH,OAAO,CAAC,gBAAgB;IA+BxB,OAAO,CAAC,mBAAmB;IAgG3B;;;;;;4GAMwG;IACxG,OAAO,CAAC,kBAAkB;IAwI1B;wFACoF;IACpF,OAAO,CAAC,YAAY;IAUpB;;;;;;;;;OASG;IACH,OAAO,CAAC,cAAc;IAYtB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,+BAA+B;IASvC;;;;;;;;;;gFAU4E;IAC5E,OAAO,CAAC,WAAW;IAInB;qGACiG;IACjG,OAAO,CAAC,OAAO;IAIf,2GAA2G;IAC3G,OAAO,CAAC,MAAM;IAId;;;;;;;;;;kDAU8C;IAC9C,OAAO,CAAC,WAAW;IASnB;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,WAAW;IAiBnB;;;;uGAImG;IACnG,OAAO,CAAC,cAAc;IActB;;;;;0FAKsF;IACtF,OAAO,CAAC,gBAAgB;IAoCxB;;;;;;;;;;;;;;;;;;OAkBG;IACH,OAAO,CAAC,eAAe;IAyBvB;;;;;;iDAM6C;IAC7C,OAAO,CAAC,WAAW;IAanB;;;;;;+EAM2E;IAC3E,OAAO,CAAC,uBAAuB;IAqE/B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,mBAAmB;IAS3B,6FAA6F;IAC7F,OAAO,CAAC,sBAAsB;YAoChB,mBAAmB;YAwDnB,eAAe;IAoD7B,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,iBAAiB;IAQzB,OAAO,CAAC,oBAAoB;IAuB5B,OAAO,CAAC,qBAAqB;IAO7B,OAAO,CAAC,oBAAoB;YAed,aAAa;IAoC3B;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,mBAAmB;IAmC3B,OAAO,CAAC,WAAW;IAmBnB,OAAO,CAAC,WAAW;CAwFpB"}
1
+ {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
@@ -8,14 +8,15 @@ 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
+ import { resolveBuildFile } from './lib/buildFiles.js';
15
16
  import { resolveCorsHeaders } from './lib/cors.js';
16
17
  import { isCsrfBlocked } from './lib/csrf.js';
17
18
  import { pickLanAddress, runningInContainer } from './lib/lanAddress.js';
18
- import { config } from './lib/config.js';
19
+ import { config, getJwtSecret } from './lib/config.js';
19
20
  import { forTeammates, resolveAgentRelayUrl, resolvePublicBaseUrl } from './lib/publicUrl.js';
20
21
  import { createTrailingRequester, systemTimerScheduler } from './lib/trailingRequester.js';
21
22
  import { getDb } from './db.js';
@@ -141,6 +142,22 @@ export function ownerKeyFor(userId, claimed) {
141
142
  }
142
143
  /** One line per rejecting socket per second, at most. See `RelayServer.logInboundRejection`. */
143
144
  const REJECT_LOG_INTERVAL_MS = 1_000;
145
+ /** One line per refused credential per minute. See `RelayServer.logConnectionRejection`. */
146
+ const CONNECTION_REJECT_LOG_INTERVAL_MS = 60_000;
147
+ export const LOGS_FORBIDDEN_MESSAGE = 'Logs are only available on the relay host. Run `tapflow logs` there.';
148
+ /**
149
+ * `?lines=` for `/api/v1/logs`: an integer in [1, 500], 100 when absent or not a number. A bare
150
+ * `Number()` let `abc` through as NaN, and `slice(-NaN)` returns the whole buffer; `-5` became
151
+ * `slice(5)`, everything but the oldest five.
152
+ */
153
+ export function parseLogLines(raw) {
154
+ if (raw === null || raw.trim() === '')
155
+ return 100;
156
+ const n = Number(raw);
157
+ if (!Number.isFinite(n))
158
+ return 100;
159
+ return Math.min(500, Math.max(1, Math.trunc(n)));
160
+ }
144
161
  /** What the door refused, as one line. The throttling that decides whether to write it is the
145
162
  * caller's, because it is per socket and this function has no state. */
146
163
  function describeRejection(failure, suppressed) {
@@ -227,6 +244,23 @@ export class RelayServer {
227
244
  /** Per-socket throttle state for `logInboundRejection`. A `WeakMap` so a closed socket's entry goes
228
245
  * with the socket — there is no cleanup to forget, unlike the maps keyed by session id nearby. */
229
246
  rejectionLog = new WeakMap();
247
+ /**
248
+ * The credential each remote socket was accepted with, re-checked by `revalidateSockets` on every
249
+ * heartbeat and after every write that can take access away (`onAuthChanged`). Local sockets have
250
+ * no entry and are never re-checked: they needed no credential.
251
+ */
252
+ principals = new WeakMap();
253
+ /** For a `first-message` socket: what its first frame may make of it. Absent means anything (local). */
254
+ firstMessageGrants = new WeakMap();
255
+ /**
256
+ * Sockets the relay closed because their credential stopped being valid. Their frames are dropped
257
+ * from the moment of the close rather than from the peer's close reply, and an agent socket among
258
+ * them skips the reconnect grace: the agent will be refused when it comes back, so holding its
259
+ * sessions for 15 s would only postpone the same end.
260
+ */
261
+ revokedSockets = new WeakSet();
262
+ /** Per-credential throttle for `logConnectionRejection`: agents retry forever on a refusal. */
263
+ connectionRejectLog = new Map();
230
264
  // Agent sockets whose sessions are being held open, and the timer that gives up on each.
231
265
  // Keyed by the dead socket, never by session id: a rebind moves sessions off that socket, so an
232
266
  // expiry that fires late has nothing left to evict. That is the invariant — releasing the hold
@@ -320,7 +354,7 @@ export class RelayServer {
320
354
  registerRoutes() {
321
355
  const u = this.uploadsDir;
322
356
  // auth
323
- this.router.get('/api/v1/auth/status', handleAuthStatus);
357
+ this.router.get('/api/v1/auth/status', (req, res) => handleAuthStatus(req, res, this.options.trustedProxies ?? []));
324
358
  this.router.post('/api/v1/auth/init', (req, res) => handleInit(req, res, this.options.trustedProxies ?? []));
325
359
  this.router.get('/api/v1/auth/me', handleMe);
326
360
  this.router.post('/api/v1/auth/login', (req, res) => handleLogin(req, res, this.options.trustedProxies ?? []));
@@ -330,7 +364,7 @@ export class RelayServer {
330
364
  this.router.post('/api/v1/auth/reset-password', handleDoReset);
331
365
  // invitations
332
366
  this.router.get('/api/v1/invitations/verify', handleVerify);
333
- this.router.post('/api/v1/invitations/accept', (req, res) => handleAccept(req, res, u));
367
+ this.router.post('/api/v1/invitations/accept', (req, res) => handleAccept(req, res, u, this.onAuthChanged));
334
368
  // apps
335
369
  this.router.get('/api/v1/apps', handleListApps);
336
370
  this.router.post('/api/v1/apps', handleCreateApp);
@@ -361,13 +395,13 @@ export class RelayServer {
361
395
  // team
362
396
  this.router.get('/api/v1/team/members', handleListMembers);
363
397
  this.router.post('/api/v1/team/invite', (req, res) => handleInvite(req, res, this.options.tunnel));
364
- this.router.patch('/api/v1/team/members/:id', handleUpdateMember);
365
- this.router.delete('/api/v1/team/members/:id', handleDeleteMember);
398
+ this.router.patch('/api/v1/team/members/:id', (req, res, params) => handleUpdateMember(req, res, params, this.onAuthChanged));
399
+ this.router.delete('/api/v1/team/members/:id', (req, res, params) => handleDeleteMember(req, res, params, this.onAuthChanged));
366
400
  this.router.post('/api/v1/team/members/:id/send-reset', (req, res, params) => handleSendMemberReset(req, res, params, this.options.tunnel));
367
401
  // tokens
368
402
  this.router.get('/api/v1/tokens', handleListTokens);
369
403
  this.router.post('/api/v1/tokens', handleCreateToken);
370
- this.router.delete('/api/v1/tokens/:id', handleRevokeToken);
404
+ this.router.delete('/api/v1/tokens/:id', (req, res, params) => handleRevokeToken(req, res, params, this.onAuthChanged));
371
405
  // settings
372
406
  this.router.get('/api/v1/settings', handleGetSettings);
373
407
  this.router.patch('/api/v1/settings', (req, res) => handleUpdateSettings(req, res, u));
@@ -377,10 +411,16 @@ export class RelayServer {
377
411
  this.router.post('/api/v1/recordings/upload', (req, res) => handleUploadRecording(req, res, this.recordingsDir));
378
412
  this.router.get('/api/v1/recordings', (req, res) => handleListRecordings(req, res));
379
413
  this.router.get('/api/v1/recordings/:filename', (req, res) => handleDownloadRecording(req, res, this.recordingsDir));
380
- // logs
414
+ // logs — the relay host only. The buffer holds the addresses of refused connections, nothing
415
+ // remote reads it (`tapflow logs` is its only consumer), and every line is also in the relay's own
416
+ // output. Same locality rule and proxy list as `auth/init` and the WebSocket handshake, so the tunnel
417
+ // listener is remote here too.
381
418
  this.router.get('/api/v1/logs', (req, res) => {
419
+ if (!resolveRequestClient(req, this.options.trustedProxies ?? []).isLocal) {
420
+ return json(res, 403, { error: LOGS_FORBIDDEN_MESSAGE });
421
+ }
382
422
  const url = new URL(req.url ?? '/', `http://localhost`);
383
- const lines = Math.min(Number(url.searchParams.get('lines') ?? 100), 500);
423
+ const lines = parseLogLines(url.searchParams.get('lines'));
384
424
  res.writeHead(200, { 'Content-Type': 'application/json' });
385
425
  res.end(JSON.stringify(this.logBuffer.slice(-lines)));
386
426
  });
@@ -416,6 +456,10 @@ export class RelayServer {
416
456
  logger.info(msg);
417
457
  }
418
458
  start() {
459
+ // Creates this install's JWT secret if it has none. Here rather than at import, so commands
460
+ // that never run a relay stop leaving one behind — and a write failure is still a boot failure.
461
+ getJwtSecret();
462
+ this.warnOrphanedAgentTokens();
419
463
  purgeExpiredRecordings(this.recordingsDir);
420
464
  this.purgeRecordingsTimer = setInterval(() => purgeExpiredRecordings(this.recordingsDir), 24 * 60 * 60 * 1000);
421
465
  this.purgeRecordingsTimer.unref();
@@ -425,8 +469,8 @@ export class RelayServer {
425
469
  purgeOldResources();
426
470
  this.purgeOldResourcesTimer = setInterval(purgeOldResources, 24 * 60 * 60 * 1000);
427
471
  this.purgeOldResourcesTimer.unref();
428
- purgeExpiredBuilds(this.recordingsDir);
429
- this.purgeBuildsTimer = setInterval(() => purgeExpiredBuilds(this.recordingsDir), 24 * 60 * 60 * 1000);
472
+ purgeExpiredBuilds(this.recordingsDir, this.uploadsDir);
473
+ this.purgeBuildsTimer = setInterval(() => purgeExpiredBuilds(this.recordingsDir, this.uploadsDir), 24 * 60 * 60 * 1000);
430
474
  this.purgeBuildsTimer.unref();
431
475
  this.flushResourcesTimer = setInterval(() => this.flushResourceBuffers(), 60_000);
432
476
  this.flushResourcesTimer.unref();
@@ -525,8 +569,28 @@ export class RelayServer {
525
569
  tunnelAddress() {
526
570
  return this.tunnelServer?.address() ?? null;
527
571
  }
572
+ /**
573
+ * An install upgrading to a relay that checks an agent token's owner stops connecting agents whose
574
+ * token belongs to a since-demoted Admin. Say how many at boot, with the fix, rather than leaving the
575
+ * operator to read 1008s off each agent.
576
+ */
577
+ warnOrphanedAgentTokens() {
578
+ const row = getDb().prepare(`
579
+ SELECT COUNT(*) AS n FROM personal_access_tokens pat JOIN users u ON u.id = pat.user_id
580
+ WHERE u.role <> 'Admin'
581
+ AND (',' || REPLACE(pat.scope, ' ', '') || ',') LIKE '%,${AGENT_SCOPE},%'
582
+ AND (pat.expires_at IS NULL OR datetime(pat.expires_at) > datetime('now'))
583
+ `).get();
584
+ if (row.n > 0) {
585
+ 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.`);
586
+ }
587
+ }
528
588
  // Terminate sockets that missed the previous pong; ping the rest. Covers all roles via wss.clients.
589
+ // Every socket's credential is re-checked on the same sweep: this is the guarantee that a removed
590
+ // member, a revoked or expired token, or an expired session cookie loses an open socket within one
591
+ // interval, whatever path the change took. `onAuthChanged` is the same check run early.
529
592
  runHeartbeat(clients = this.wss.clients) {
593
+ this.revalidateSockets(clients);
530
594
  for (const ws of clients) {
531
595
  if (!this.isAlive(ws)) {
532
596
  ws.terminate();
@@ -536,6 +600,77 @@ export class RelayServer {
536
600
  ws.ping();
537
601
  }
538
602
  }
603
+ /**
604
+ * Injected into the handlers whose write can take access away — member update and removal, token
605
+ * revocation, invitation accept — and called after the write commits. It carries no argument on
606
+ * purpose: the sweep re-derives every socket's standing from the database, so no handler has to know
607
+ * which sockets its change affects.
608
+ */
609
+ onAuthChanged = () => {
610
+ this.revalidateSockets(this.wss.clients);
611
+ };
612
+ revalidateSockets(clients) {
613
+ const now = Math.floor(Date.now() / 1000);
614
+ for (const ws of clients) {
615
+ const principal = this.principals.get(ws);
616
+ if (!principal || this.revokedSockets.has(ws) || ws.readyState !== WebSocket.OPEN)
617
+ continue;
618
+ let reason;
619
+ try {
620
+ const pat = principal.via === 'pat' ? findPat(principal.patId) : null;
621
+ const role = this.wsRoles.get(ws);
622
+ reason = revalidatePrincipal(principal, role, {
623
+ userExists: principal.via === 'cookie' ? findUser(principal.userId) !== null : true,
624
+ pat,
625
+ }, now);
626
+ // A socket that has not introduced itself yet takes what a fresh handshake would give it now.
627
+ if (reason === null && role === undefined && pat) {
628
+ const again = classifyConnection({ isLocal: false, hasCookieAuth: false, patScopes: pat.scopes, patOwnerRole: pat.ownerRole });
629
+ if (again.action === 'accept') {
630
+ this.firstMessageGrants.set(ws, again.role === 'browser'
631
+ ? { mayBrowse: true, mayRegisterAgent: false }
632
+ : { mayBrowse: again.mayBrowse, mayRegisterAgent: again.mayRegisterAgent });
633
+ }
634
+ }
635
+ }
636
+ catch (err) {
637
+ // A database fault is not a revocation: closing every socket on a transient `SQLITE_BUSY` would
638
+ // disconnect the whole team. The next sweep asks again.
639
+ logger.error('socket re-validation failed:', err);
640
+ return;
641
+ }
642
+ if (reason !== null)
643
+ this.closeForAuth(ws, reason);
644
+ }
645
+ }
646
+ closeForAuth(ws, reason) {
647
+ this.revokedSockets.add(ws);
648
+ this.pushLog(`WS connection closed — ${reason}`);
649
+ ws.close(1008, reason);
650
+ }
651
+ /**
652
+ * One line per refused credential per interval. Agents retry forever on a refusal, so logging each
653
+ * attempt would fill the 500-line buffer with one agent's reconnects. Keyed by PAT id when there is
654
+ * one and by address otherwise; the first refusal of each is always written.
655
+ */
656
+ logConnectionRejection(addr, reason, patId) {
657
+ const key = patId !== undefined ? `pat:${patId}` : `addr:${addr}`;
658
+ const now = Date.now();
659
+ const state = this.connectionRejectLog.get(key);
660
+ if (state && now - state.at < CONNECTION_REJECT_LOG_INTERVAL_MS) {
661
+ state.suppressed++;
662
+ return;
663
+ }
664
+ if (this.connectionRejectLog.size > 1000) {
665
+ for (const [k, v] of this.connectionRejectLog) {
666
+ if (now - v.at >= CONNECTION_REJECT_LOG_INTERVAL_MS)
667
+ this.connectionRejectLog.delete(k);
668
+ }
669
+ }
670
+ const also = state && state.suppressed > 0 ? ` (+${state.suppressed} more in the last minute)` : '';
671
+ this.connectionRejectLog.set(key, { at: now, suppressed: 0 });
672
+ this.pushLog(`WS connection rejected from ${addr} — ${reason}${also}`);
673
+ }
539
674
  /**
540
675
  * Has this socket answered recently enough to be treated as present?
541
676
  *
@@ -696,17 +831,50 @@ export class RelayServer {
696
831
  trustedProxies: this.options.trustedProxies ?? [],
697
832
  viaTunnel,
698
833
  });
699
- const hasCookieAuth = getAuth(request) !== null;
700
- // DB lookup — only when the connection can't be classified without it (remote, no cookie).
701
- const pat = !isLocal && !hasCookieAuth ? verifyPat(request) : null;
702
- const decision = classifyConnection({
703
- isLocal,
704
- hasCookieAuth,
705
- patScopes: pat ? pat.scope.split(',').map((s) => s.trim()) : null,
706
- });
707
- if (decision.action === 'reject') {
708
- this.pushLog(`WS connection rejected from ${addr} — no credentials (agents: PAT with 'agent' scope via --token)`);
709
- ws.close(1008, decision.reason);
834
+ // **Everything that reads the database is inside this try.** `getAuth` and `verifyPat` both query,
835
+ // and this handler runs synchronously in the `connection` event: a throw here is uncaught and takes
836
+ // the relay process down with every session on it. A fault closes this one socket instead (1011),
837
+ // and is never read as "no credentials".
838
+ let decision;
839
+ let userId;
840
+ try {
841
+ // Resolved once: `hasCookieAuth`, the owner key and the principal all read this one answer. Two
842
+ // calls were two queries, with a window where they could disagree.
843
+ const cookie = getAuth(request);
844
+ // DB lookup — only when the connection can't be classified without it (remote, no cookie).
845
+ const pat = !isLocal && !cookie ? verifyPat(request) : null;
846
+ decision = classifyConnection({
847
+ isLocal,
848
+ hasCookieAuth: cookie !== null,
849
+ patScopes: pat?.scopes ?? null,
850
+ patOwnerRole: pat?.ownerRole ?? null,
851
+ });
852
+ if (decision.action === 'reject') {
853
+ this.logConnectionRejection(addr, decision.reason, pat?.patId);
854
+ ws.close(1008, decision.reason);
855
+ return;
856
+ }
857
+ // Only an accepted token was used; a refused one keeps its "last used".
858
+ if (pat)
859
+ touchPat(pat.patId);
860
+ userId = cookie?.userId ?? pat?.userId;
861
+ if (!isLocal) {
862
+ if (cookie)
863
+ this.principals.set(ws, { via: 'cookie', userId: cookie.userId, jwtExp: cookie.exp });
864
+ else if (pat)
865
+ this.principals.set(ws, { via: 'pat', userId: pat.userId, patId: pat.patId });
866
+ }
867
+ if (decision.role === 'first-message') {
868
+ this.firstMessageGrants.set(ws, { mayBrowse: decision.mayBrowse, mayRegisterAgent: decision.mayRegisterAgent });
869
+ }
870
+ else if (pat?.scopes.includes(AGENT_SCOPE)) {
871
+ // A `view,agent` token whose owner is no longer an Admin: browsing only, and a handshake is told why.
872
+ this.firstMessageGrants.set(ws, { mayBrowse: true, mayRegisterAgent: false });
873
+ }
874
+ }
875
+ catch (err) {
876
+ logger.error(`WebSocket handshake failed for ${addr}:`, err);
877
+ ws.close(1011, 'Internal error');
710
878
  return;
711
879
  }
712
880
  this.wsExternal.set(ws, isExternalAddress(addr));
@@ -718,8 +886,8 @@ export class RelayServer {
718
886
  // so every agent, `mcp-server` and `flow-runner` connection would have been `anon:` — and the whole
719
887
  // point of pairing the claim with a user is that a leaked client id is useless to anyone else. Two
720
888
  // processes on the same PAT are the same principal, which is right; two on different PATs are not,
721
- // which is what this restores. `pat` is already resolved above and was being discarded.
722
- this.ownerKey.set(ws, ownerKeyFor(getAuth(request)?.userId ?? pat?.userId, claimed));
889
+ // which is what this restores.
890
+ this.ownerKey.set(ws, ownerKeyFor(userId, claimed));
723
891
  // Heartbeat liveness: a fresh socket counts as having just answered, and each pong renews it.
724
892
  this.lastPongAt.set(ws, Date.now());
725
893
  ws.on('pong', () => this.lastPongAt.set(ws, Date.now()));
@@ -727,6 +895,10 @@ export class RelayServer {
727
895
  this.wsRoles.set(ws, 'browser');
728
896
  // 'first-message' → role is determined by the first message (agent:register / stream:register)
729
897
  ws.on('message', (data, isBinary) => {
898
+ // Closed for a credential that stopped being valid: nothing it sends after that counts, even
899
+ // before its close reply arrives.
900
+ if (this.revokedSockets.has(ws))
901
+ return;
730
902
  if (isBinary) {
731
903
  // Binary frames arrive on the dedicated stream WS, route to the session's browser
732
904
  const session = this.sessions.getByStreamSocket(ws);
@@ -800,9 +972,9 @@ export class RelayServer {
800
972
  return;
801
973
  if (!inbound.ok) {
802
974
  // **After the role gate, never before it**, so a browser spoofing an agent-only type gets 1008
803
- // and no reply. That is the whole of the claim: an agent- or stream-role socket sending a
804
- // malformed browser request passes the gate and *is* answered, because the gate only refuses a
805
- // `browser` role sending a non-browser type. Harmless, and worth stating rather than implying
975
+ // and no reply. That is the whole of the claim: an agent- or stream-role socket whose token also
976
+ // carries `view` (or a local one) sending a malformed browser request passes the gate and *is*
977
+ // answered — without `view` it was already closed above. Harmless, and worth stating rather than implying
806
978
  // otherwise — such a socket already gets an answer on the well-formed path (`refuseInput` tells
807
979
  // it `not-session-owner`), and this reply says strictly less than that one.
808
980
  if (inbound.reason === 'bad-payload')
@@ -884,22 +1056,40 @@ export class RelayServer {
884
1056
  // inverts the check below: a socket whose first frame is `screenshot:done` would be handed the
885
1057
  // `agent` role and then waved through the gate that exists to refuse exactly that.
886
1058
  const handshake = type === 'agent:register' || type === 'stream:register';
1059
+ // What the credentials allow the first frame to make of this socket (`classifyConnection`). No entry
1060
+ // is a socket that may become anything: a local one.
1061
+ const grants = this.firstMessageGrants.get(ws);
887
1062
  if (!this.wsRoles.has(ws)) {
888
1063
  // A handshake that did not parse confers nothing. Returning here rather than falling through to
889
1064
  // `browser` is what keeps an agent whose register is malformed from being closed with
890
1065
  // `Forbidden` — the caller has already logged it, and the next frame still gets to introduce it.
891
1066
  if (handshake && !inbound.ok)
892
1067
  return false;
1068
+ if (handshake && grants && !grants.mayRegisterAgent) {
1069
+ ws.close(1008, WS_AGENT_OWNER_REASON);
1070
+ return false;
1071
+ }
893
1072
  if (type === 'agent:register')
894
1073
  this.wsRoles.set(ws, 'agent');
895
1074
  else if (type === 'stream:register')
896
1075
  this.wsRoles.set(ws, 'stream');
897
- // Local connection whose first message is not an agent/stream handshake — treat it as a browser
898
- // (e.g. dashboard opened on the same machine). This fallback is what puts such a socket under
899
- // the gate below.
1076
+ // A first message that is not an agent/stream handshake makes the socket a browser (e.g. the
1077
+ // dashboard opened on the same machine) — but only when its credentials could have opened a
1078
+ // browser socket directly. An `agent`-only token used to land here as a browser too.
1079
+ else if (grants && !grants.mayBrowse) {
1080
+ ws.close(1008, WS_SCOPE_REASON);
1081
+ return false;
1082
+ }
1083
+ // This fallback is what puts such a socket under the gate below.
900
1084
  else
901
1085
  this.wsRoles.set(ws, 'browser');
902
1086
  }
1087
+ // A `view,agent` token whose owner lost Admin was accepted as a browser; its handshake is told why
1088
+ // it cannot register rather than a bare `Forbidden`.
1089
+ if (handshake && this.wsRoles.get(ws) === 'browser' && grants && !grants.mayRegisterAgent) {
1090
+ ws.close(1008, WS_AGENT_OWNER_REASON);
1091
+ return false;
1092
+ }
903
1093
  // Browser sockets must not spoof agent control messages.
904
1094
  //
905
1095
  // This was a hand-copied array of 29 literals with two type assertions holding it against the
@@ -909,6 +1099,17 @@ export class RelayServer {
909
1099
  ws.close(1008, 'Forbidden');
910
1100
  return false;
911
1101
  }
1102
+ // The other direction of the same rule. An agent or stream socket on a token without `view` may
1103
+ // not act as a browser after its handshake either — `session:start`, `input:*`, `device:*` — or an
1104
+ // `agent`-only token would drive devices just by registering first. No agent needs this: their
1105
+ // text frames go through `sendMsg`/`sendOn`, typed `AgentToRelay | AgentToBrowser`, and the stream
1106
+ // socket sends only `stream:register`; none of those types is browser-direction (checked against
1107
+ // `BrowserToRelay` when this was added). Local agents carry no grants and are not gated.
1108
+ const role = this.wsRoles.get(ws);
1109
+ if ((role === 'agent' || role === 'stream') && grants && !grants.mayBrowse && directionOf(type) === 'browser') {
1110
+ ws.close(1008, WS_SCOPE_REASON);
1111
+ return false;
1112
+ }
912
1113
  return true;
913
1114
  }
914
1115
  /**
@@ -1449,9 +1650,17 @@ export class RelayServer {
1449
1650
  */
1450
1651
  holdAgentSocket(ws) {
1451
1652
  // Shutting down: nothing is coming back, and arming a timer here would leave it running after
1452
- // `stop()` resolved.
1653
+ // `stop()` resolved. Closed by the relay for a credential that is no longer valid: the agent will be
1654
+ // refused when it returns, so a hold would only postpone the same end.
1453
1655
  if (this.stopping)
1454
1656
  return this.evictAgentSocket(ws);
1657
+ if (this.revokedSockets.has(ws)) {
1658
+ const evicted = this.evictAgentSocket(ws);
1659
+ // `evictAgentSocket` keeps the resource entry when there were no sessions to end.
1660
+ if (!evicted)
1661
+ this.sessions.removeResources(ws);
1662
+ return evicted;
1663
+ }
1455
1664
  const sessions = this.sessions.getAllByAgentSocket(ws);
1456
1665
  if (sessions.length === 0) {
1457
1666
  // No sessions to hold, but the socket may still own a resource entry — `evictAgentSocket`
@@ -2084,9 +2293,15 @@ export class RelayServer {
2084
2293
  // A missing file is answered rather than thrown for the same reason the lookup above is: the
2085
2294
  // build row outlives its file whenever a bind mount goes away or a purge half-ran, and an
2086
2295
  // unanswered install is a spinner that never stops and a caller that times out with no cause.
2296
+ //
2297
+ // Resolved rather than read verbatim: the stored path names wherever the data directory was at
2298
+ // upload time, and a migrated, moved or restored install keeps the file under a different one.
2299
+ const filePath = resolveBuildFile(build.file_path, this.uploadsDir);
2087
2300
  let bytes;
2088
2301
  try {
2089
- bytes = fs.statSync(build.file_path).size;
2302
+ if (filePath === null)
2303
+ throw new Error('not found');
2304
+ bytes = fs.statSync(filePath).size;
2090
2305
  }
2091
2306
  catch {
2092
2307
  return fail('The relay cannot read this build file. It may have been deleted — re-upload the build.');
@@ -2097,10 +2312,10 @@ export class RelayServer {
2097
2312
  requestId,
2098
2313
  payload: {
2099
2314
  // Still sent, and sent first: an agent that predates `build-download` reads only this.
2100
- filePath: build.file_path,
2315
+ filePath,
2101
2316
  bundleId: build.bundle_id,
2102
- buildTicket: this.buildTickets.mint(msg.buildId, build.file_path),
2103
- buildName: path.basename(build.file_path),
2317
+ buildTicket: this.buildTickets.mint(msg.buildId, filePath),
2318
+ buildName: path.basename(filePath),
2104
2319
  buildBytes: bytes,
2105
2320
  },
2106
2321
  });
@@ -2330,8 +2545,18 @@ export class RelayServer {
2330
2545
  }
2331
2546
  }
2332
2547
  if (url.startsWith('/uploads/')) {
2333
- if (!requireViewAuth(req, res))
2548
+ // Outside the router, so outside its catch: the auth check reads the database, and a throw here
2549
+ // would be an unhandled rejection that ends the process.
2550
+ try {
2551
+ if (!requireViewAuth(req, res))
2552
+ return;
2553
+ }
2554
+ catch (err) {
2555
+ logger.error('upload auth failed:', err);
2556
+ if (!res.headersSent)
2557
+ json(res, 500, { error: 'Internal server error' });
2334
2558
  return;
2559
+ }
2335
2560
  this.serveUpload(req, res);
2336
2561
  return;
2337
2562
  }