gitspace 0.2.0-rc.1

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 (318) hide show
  1. package/.claude/settings.local.json +21 -0
  2. package/.gitspace/bundle.json +50 -0
  3. package/.gitspace/select/01-status.sh +40 -0
  4. package/.gitspace/setup/01-install-deps.sh +12 -0
  5. package/.gitspace/setup/02-typecheck.sh +16 -0
  6. package/AGENTS.md +439 -0
  7. package/CLAUDE.md +1 -0
  8. package/LICENSE +25 -0
  9. package/README.md +607 -0
  10. package/bin/gssh +62 -0
  11. package/bun.lock +647 -0
  12. package/docs/CONNECTION.md +623 -0
  13. package/docs/GATEWAY-WORKER.md +319 -0
  14. package/docs/GETTING-STARTED.md +448 -0
  15. package/docs/GITSPACE-PLATFORM.md +1819 -0
  16. package/docs/INFRASTRUCTURE.md +1347 -0
  17. package/docs/PROTOCOL.md +619 -0
  18. package/docs/QUICKSTART.md +174 -0
  19. package/docs/RELAY.md +327 -0
  20. package/docs/REMOTE-DESIGN.md +549 -0
  21. package/docs/ROADMAP.md +564 -0
  22. package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
  23. package/docs/STACK-DESIGN.md +588 -0
  24. package/docs/UNIFIED_ARCHITECTURE.md +292 -0
  25. package/experiments/pty-benchmark.ts +148 -0
  26. package/experiments/pty-latency.ts +100 -0
  27. package/experiments/router/client.ts +199 -0
  28. package/experiments/router/protocol.ts +74 -0
  29. package/experiments/router/router.ts +217 -0
  30. package/experiments/router/session.ts +180 -0
  31. package/experiments/router/test.ts +133 -0
  32. package/experiments/socket-bandwidth.ts +77 -0
  33. package/homebrew/gitspace.rb +45 -0
  34. package/landing-page/ATTRIBUTIONS.md +3 -0
  35. package/landing-page/README.md +11 -0
  36. package/landing-page/bun.lock +801 -0
  37. package/landing-page/guidelines/Guidelines.md +61 -0
  38. package/landing-page/index.html +37 -0
  39. package/landing-page/package.json +90 -0
  40. package/landing-page/postcss.config.mjs +15 -0
  41. package/landing-page/public/_redirects +1 -0
  42. package/landing-page/public/favicon.png +0 -0
  43. package/landing-page/src/app/App.tsx +53 -0
  44. package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
  45. package/landing-page/src/app/components/ui/accordion.tsx +66 -0
  46. package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
  47. package/landing-page/src/app/components/ui/alert.tsx +66 -0
  48. package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
  49. package/landing-page/src/app/components/ui/avatar.tsx +53 -0
  50. package/landing-page/src/app/components/ui/badge.tsx +46 -0
  51. package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
  52. package/landing-page/src/app/components/ui/button.tsx +57 -0
  53. package/landing-page/src/app/components/ui/calendar.tsx +75 -0
  54. package/landing-page/src/app/components/ui/card.tsx +92 -0
  55. package/landing-page/src/app/components/ui/carousel.tsx +241 -0
  56. package/landing-page/src/app/components/ui/chart.tsx +353 -0
  57. package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
  58. package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
  59. package/landing-page/src/app/components/ui/command.tsx +177 -0
  60. package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
  61. package/landing-page/src/app/components/ui/dialog.tsx +135 -0
  62. package/landing-page/src/app/components/ui/drawer.tsx +132 -0
  63. package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
  64. package/landing-page/src/app/components/ui/form.tsx +168 -0
  65. package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
  66. package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
  67. package/landing-page/src/app/components/ui/input.tsx +21 -0
  68. package/landing-page/src/app/components/ui/label.tsx +24 -0
  69. package/landing-page/src/app/components/ui/menubar.tsx +276 -0
  70. package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
  71. package/landing-page/src/app/components/ui/pagination.tsx +127 -0
  72. package/landing-page/src/app/components/ui/popover.tsx +48 -0
  73. package/landing-page/src/app/components/ui/progress.tsx +31 -0
  74. package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
  75. package/landing-page/src/app/components/ui/resizable.tsx +56 -0
  76. package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
  77. package/landing-page/src/app/components/ui/select.tsx +189 -0
  78. package/landing-page/src/app/components/ui/separator.tsx +28 -0
  79. package/landing-page/src/app/components/ui/sheet.tsx +139 -0
  80. package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
  81. package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
  82. package/landing-page/src/app/components/ui/slider.tsx +63 -0
  83. package/landing-page/src/app/components/ui/sonner.tsx +25 -0
  84. package/landing-page/src/app/components/ui/switch.tsx +31 -0
  85. package/landing-page/src/app/components/ui/table.tsx +116 -0
  86. package/landing-page/src/app/components/ui/tabs.tsx +66 -0
  87. package/landing-page/src/app/components/ui/textarea.tsx +18 -0
  88. package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
  89. package/landing-page/src/app/components/ui/toggle.tsx +47 -0
  90. package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
  91. package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
  92. package/landing-page/src/app/components/ui/utils.ts +6 -0
  93. package/landing-page/src/components/docs/DocsContent.tsx +718 -0
  94. package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
  95. package/landing-page/src/components/landing/CTA.tsx +59 -0
  96. package/landing-page/src/components/landing/Comparison.tsx +84 -0
  97. package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
  98. package/landing-page/src/components/landing/Features.tsx +201 -0
  99. package/landing-page/src/components/landing/Hero.tsx +142 -0
  100. package/landing-page/src/components/landing/Pricing.tsx +140 -0
  101. package/landing-page/src/components/landing/Roadmap.tsx +86 -0
  102. package/landing-page/src/components/landing/Security.tsx +81 -0
  103. package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
  104. package/landing-page/src/components/landing/UseCases.tsx +55 -0
  105. package/landing-page/src/components/landing/Workflow.tsx +101 -0
  106. package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
  107. package/landing-page/src/components/layout/Footer.tsx +55 -0
  108. package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
  109. package/landing-page/src/components/ui/badge.tsx +39 -0
  110. package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
  111. package/landing-page/src/components/ui/button.tsx +57 -0
  112. package/landing-page/src/components/ui/card.tsx +79 -0
  113. package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
  114. package/landing-page/src/components/ui/separator.tsx +28 -0
  115. package/landing-page/src/lib/utils.ts +6 -0
  116. package/landing-page/src/main.tsx +10 -0
  117. package/landing-page/src/pages/Dashboard.tsx +133 -0
  118. package/landing-page/src/pages/DocsPage.tsx +79 -0
  119. package/landing-page/src/pages/LandingPage.tsx +31 -0
  120. package/landing-page/src/pages/TerminalView.tsx +106 -0
  121. package/landing-page/src/styles/fonts.css +0 -0
  122. package/landing-page/src/styles/index.css +3 -0
  123. package/landing-page/src/styles/tailwind.css +4 -0
  124. package/landing-page/src/styles/theme.css +181 -0
  125. package/landing-page/vite.config.ts +19 -0
  126. package/npm/darwin-arm64/bin/gssh +0 -0
  127. package/npm/darwin-arm64/package.json +20 -0
  128. package/package.json +74 -0
  129. package/scripts/build.ts +284 -0
  130. package/scripts/release.ts +140 -0
  131. package/src/__tests__/test-utils.ts +298 -0
  132. package/src/commands/__tests__/serve-messages.test.ts +190 -0
  133. package/src/commands/access.ts +298 -0
  134. package/src/commands/add.ts +452 -0
  135. package/src/commands/auth.ts +364 -0
  136. package/src/commands/connect.ts +287 -0
  137. package/src/commands/directory.ts +16 -0
  138. package/src/commands/host.ts +396 -0
  139. package/src/commands/identity.ts +184 -0
  140. package/src/commands/list.ts +200 -0
  141. package/src/commands/relay.ts +315 -0
  142. package/src/commands/remove.ts +241 -0
  143. package/src/commands/serve.ts +1493 -0
  144. package/src/commands/share.ts +456 -0
  145. package/src/commands/status.ts +125 -0
  146. package/src/commands/switch.ts +353 -0
  147. package/src/commands/tmux.ts +317 -0
  148. package/src/core/__tests__/access.test.ts +240 -0
  149. package/src/core/access.ts +277 -0
  150. package/src/core/bundle.ts +342 -0
  151. package/src/core/config.ts +510 -0
  152. package/src/core/git.ts +317 -0
  153. package/src/core/github.ts +151 -0
  154. package/src/core/identity.ts +631 -0
  155. package/src/core/linear.ts +225 -0
  156. package/src/core/shell.ts +161 -0
  157. package/src/core/trusted-relays.ts +315 -0
  158. package/src/index.ts +821 -0
  159. package/src/lib/remote-session/index.ts +7 -0
  160. package/src/lib/remote-session/protocol.ts +267 -0
  161. package/src/lib/remote-session/session-handler.ts +581 -0
  162. package/src/lib/remote-session/workspace-scanner.ts +167 -0
  163. package/src/lib/tmux-lite/README.md +81 -0
  164. package/src/lib/tmux-lite/cli.ts +796 -0
  165. package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
  166. package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
  167. package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
  168. package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
  169. package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
  170. package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
  171. package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
  172. package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
  173. package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
  174. package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
  175. package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
  176. package/src/lib/tmux-lite/crypto/frames.ts +141 -0
  177. package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
  178. package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
  179. package/src/lib/tmux-lite/crypto/identity.ts +286 -0
  180. package/src/lib/tmux-lite/crypto/index.ts +51 -0
  181. package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
  182. package/src/lib/tmux-lite/crypto/invites.ts +215 -0
  183. package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
  184. package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
  185. package/src/lib/tmux-lite/crypto/keys.ts +47 -0
  186. package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
  187. package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
  188. package/src/lib/tmux-lite/handshake-handler.ts +451 -0
  189. package/src/lib/tmux-lite/protocol.test.ts +307 -0
  190. package/src/lib/tmux-lite/protocol.ts +266 -0
  191. package/src/lib/tmux-lite/relay-client.ts +506 -0
  192. package/src/lib/tmux-lite/server.ts +1250 -0
  193. package/src/lib/tmux-lite/shell-integration.sh +37 -0
  194. package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
  195. package/src/lib/tmux-lite/terminal-queries.ts +49 -0
  196. package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
  197. package/src/relay/__tests__/helpers/auth.ts +354 -0
  198. package/src/relay/__tests__/helpers/ports.ts +51 -0
  199. package/src/relay/__tests__/protocol-validation.test.ts +265 -0
  200. package/src/relay/authorization.ts +303 -0
  201. package/src/relay/embedded-assets.generated.d.ts +15 -0
  202. package/src/relay/identity.ts +352 -0
  203. package/src/relay/index.ts +57 -0
  204. package/src/relay/pipes.test.ts +427 -0
  205. package/src/relay/pipes.ts +195 -0
  206. package/src/relay/protocol.ts +804 -0
  207. package/src/relay/registries.test.ts +437 -0
  208. package/src/relay/registries.ts +593 -0
  209. package/src/relay/server.test.ts +1323 -0
  210. package/src/relay/server.ts +1092 -0
  211. package/src/relay/signing.ts +238 -0
  212. package/src/relay/types.ts +69 -0
  213. package/src/serve/client-session-manager.ts +622 -0
  214. package/src/serve/daemon.ts +497 -0
  215. package/src/serve/pty-session.ts +236 -0
  216. package/src/serve/types.ts +169 -0
  217. package/src/shared/components/Flow.tsx +453 -0
  218. package/src/shared/components/Flow.tui.tsx +343 -0
  219. package/src/shared/components/Flow.web.tsx +442 -0
  220. package/src/shared/components/Inbox.tsx +446 -0
  221. package/src/shared/components/Inbox.tui.tsx +262 -0
  222. package/src/shared/components/Inbox.web.tsx +329 -0
  223. package/src/shared/components/MachineList.tsx +187 -0
  224. package/src/shared/components/MachineList.tui.tsx +161 -0
  225. package/src/shared/components/MachineList.web.tsx +210 -0
  226. package/src/shared/components/ProjectList.tsx +176 -0
  227. package/src/shared/components/ProjectList.tui.tsx +109 -0
  228. package/src/shared/components/ProjectList.web.tsx +143 -0
  229. package/src/shared/components/SpacesBrowser.tsx +332 -0
  230. package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
  231. package/src/shared/components/SpacesBrowser.web.tsx +221 -0
  232. package/src/shared/components/index.ts +103 -0
  233. package/src/shared/hooks/index.ts +16 -0
  234. package/src/shared/hooks/useNavigation.ts +226 -0
  235. package/src/shared/index.ts +122 -0
  236. package/src/shared/providers/LocalMachineProvider.ts +425 -0
  237. package/src/shared/providers/MachineProvider.ts +165 -0
  238. package/src/shared/providers/RemoteMachineProvider.ts +444 -0
  239. package/src/shared/providers/index.ts +26 -0
  240. package/src/shared/types.ts +145 -0
  241. package/src/tui/adapters.ts +120 -0
  242. package/src/tui/app.tsx +1816 -0
  243. package/src/tui/components/Terminal.tsx +580 -0
  244. package/src/tui/hooks/index.ts +35 -0
  245. package/src/tui/hooks/useAppState.ts +314 -0
  246. package/src/tui/hooks/useDaemonStatus.ts +174 -0
  247. package/src/tui/hooks/useInboxTUI.ts +113 -0
  248. package/src/tui/hooks/useRemoteMachines.ts +209 -0
  249. package/src/tui/index.ts +24 -0
  250. package/src/tui/state.ts +299 -0
  251. package/src/tui/terminal-bracketed-paste.test.ts +45 -0
  252. package/src/tui/terminal-bracketed-paste.ts +47 -0
  253. package/src/types/bundle.ts +112 -0
  254. package/src/types/config.ts +89 -0
  255. package/src/types/errors.ts +206 -0
  256. package/src/types/identity.ts +284 -0
  257. package/src/types/workspace-fuzzy.ts +49 -0
  258. package/src/types/workspace.ts +151 -0
  259. package/src/utils/bun-socket-writer.ts +80 -0
  260. package/src/utils/deps.ts +127 -0
  261. package/src/utils/fuzzy-match.ts +125 -0
  262. package/src/utils/logger.ts +127 -0
  263. package/src/utils/markdown.ts +254 -0
  264. package/src/utils/onboarding.ts +229 -0
  265. package/src/utils/prompts.ts +114 -0
  266. package/src/utils/run-commands.ts +112 -0
  267. package/src/utils/run-scripts.ts +142 -0
  268. package/src/utils/sanitize.ts +98 -0
  269. package/src/utils/secrets.ts +122 -0
  270. package/src/utils/shell-escape.ts +40 -0
  271. package/src/utils/utf8.ts +79 -0
  272. package/src/utils/workspace-state.ts +47 -0
  273. package/src/web/README.md +73 -0
  274. package/src/web/bun.lock +575 -0
  275. package/src/web/eslint.config.js +23 -0
  276. package/src/web/index.html +16 -0
  277. package/src/web/package.json +37 -0
  278. package/src/web/public/vite.svg +1 -0
  279. package/src/web/src/App.tsx +604 -0
  280. package/src/web/src/assets/react.svg +1 -0
  281. package/src/web/src/components/Terminal.tsx +207 -0
  282. package/src/web/src/hooks/useRelayConnection.ts +224 -0
  283. package/src/web/src/hooks/useTerminal.ts +699 -0
  284. package/src/web/src/index.css +55 -0
  285. package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
  286. package/src/web/src/lib/crypto/frames.ts +205 -0
  287. package/src/web/src/lib/crypto/handshake.ts +396 -0
  288. package/src/web/src/lib/crypto/identity.ts +128 -0
  289. package/src/web/src/lib/crypto/keyexchange.ts +246 -0
  290. package/src/web/src/lib/crypto/relay-signing.ts +53 -0
  291. package/src/web/src/lib/invite.ts +58 -0
  292. package/src/web/src/lib/storage/identity-store.ts +94 -0
  293. package/src/web/src/main.tsx +10 -0
  294. package/src/web/src/types/identity.ts +45 -0
  295. package/src/web/tsconfig.app.json +28 -0
  296. package/src/web/tsconfig.json +7 -0
  297. package/src/web/tsconfig.node.json +26 -0
  298. package/src/web/vite.config.ts +31 -0
  299. package/todo-security.md +92 -0
  300. package/tsconfig.json +23 -0
  301. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
  302. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
  303. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
  304. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
  305. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
  306. package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
  307. package/worker/bun.lock +237 -0
  308. package/worker/package.json +22 -0
  309. package/worker/schema.sql +96 -0
  310. package/worker/src/handlers/auth.ts +451 -0
  311. package/worker/src/handlers/subdomains.ts +376 -0
  312. package/worker/src/handlers/user.ts +98 -0
  313. package/worker/src/index.ts +70 -0
  314. package/worker/src/middleware/auth.ts +152 -0
  315. package/worker/src/services/cloudflare.ts +609 -0
  316. package/worker/src/types.ts +96 -0
  317. package/worker/tsconfig.json +15 -0
  318. package/worker/wrangler.toml +26 -0
@@ -0,0 +1,1092 @@
1
+ /**
2
+ * Relay server - Bun.serve() WebSocket server with self-registration
3
+ *
4
+ * Endpoints:
5
+ * - GET /ws?role=<machine|client> - WebSocket upgrade
6
+ * - GET /health - Health check
7
+ *
8
+ * Protocol:
9
+ * - Machines authenticate via Ed25519 challenge-response
10
+ * - Clients connect via invites or directly if authorized
11
+ * - Data is routed point-to-point using connectionId
12
+ */
13
+
14
+ import { join, resolve, sep } from "path";
15
+ import { randomBytes } from "crypto";
16
+ import type { Server, ServerWebSocket } from "bun";
17
+ import type { RelayConfig, WebSocketData } from "./types";
18
+ import { ed25519 } from "@noble/curves/ed25519.js";
19
+ import { signMessage, verifySignedMessage, getSignerPublicKey, type SignedMessage } from "./signing.js";
20
+ import { PROTOCOL_VERSION } from "./protocol.js";
21
+ import { formatRelayFingerprint, type RelayIdentity } from "./identity.js";
22
+ import { isAuthorized, getAuthorizedMachine } from "./authorization.js";
23
+ import { deriveIdentityId } from "../lib/tmux-lite/crypto/identity.js";
24
+
25
+ /**
26
+ * Path to web terminal dist files (built by Vite)
27
+ * Used for development mode when assets aren't embedded
28
+ */
29
+ const WEB_DIST_PATH = join(import.meta.dir, "../web/dist");
30
+
31
+ /**
32
+ * Try to import embedded assets (only available in compiled binary)
33
+ */
34
+ let embeddedAssets: typeof import("./embedded-assets.generated") | null = null;
35
+ try {
36
+ embeddedAssets = await import("./embedded-assets.generated.js");
37
+ } catch {
38
+ // Not running as compiled binary - use filesystem
39
+ }
40
+
41
+ /**
42
+ * Check if we have embedded assets available
43
+ */
44
+ function hasEmbeddedAssets(): boolean {
45
+ return embeddedAssets?.hasEmbeddedAssets() ?? false;
46
+ }
47
+
48
+ /**
49
+ * Get content type for a file extension
50
+ */
51
+ function getContentType(pathname: string): string {
52
+ const ext = pathname.split(".").pop();
53
+ const contentTypes: Record<string, string> = {
54
+ html: "text/html; charset=utf-8",
55
+ js: "application/javascript",
56
+ css: "text/css",
57
+ wasm: "application/wasm",
58
+ json: "application/json",
59
+ svg: "image/svg+xml",
60
+ png: "image/png",
61
+ jpg: "image/jpeg",
62
+ ico: "image/x-icon",
63
+ };
64
+ return contentTypes[ext || ""] || "application/octet-stream";
65
+ }
66
+
67
+ /**
68
+ * Serve a static file - tries embedded assets first, falls back to filesystem
69
+ */
70
+ async function serveStaticFile(pathname: string): Promise<Response | null> {
71
+ // Try embedded assets first (compiled binary)
72
+ if (hasEmbeddedAssets() && embeddedAssets) {
73
+ const blob = embeddedAssets.getEmbeddedFile(pathname);
74
+ if (blob) {
75
+ return new Response(blob, {
76
+ headers: { "Content-Type": getContentType(pathname) },
77
+ });
78
+ }
79
+ }
80
+
81
+ // Fall back to filesystem (development mode)
82
+ const filePath = pathname === "/" ? "/index.html" : pathname;
83
+ const resolvedPath = resolveAssetPath(filePath);
84
+ if (!resolvedPath) return null;
85
+
86
+ const file = Bun.file(resolvedPath);
87
+ if (await file.exists()) {
88
+ return new Response(file, {
89
+ headers: { "Content-Type": getContentType(pathname) },
90
+ });
91
+ }
92
+
93
+ return null;
94
+ }
95
+ import {
96
+ registerMachine,
97
+ getMachine,
98
+ setMachineConnection,
99
+ registerInvite,
100
+ getInvite,
101
+ isInviteValid,
102
+ useInvite,
103
+ authorizeClient,
104
+ revokeClientAuthorization,
105
+ getAllMachinesWithAuthStatus,
106
+ getRegistryStats,
107
+ getEffectiveAccessList,
108
+ addGlobalAccess,
109
+ removeGlobalAccess,
110
+ broadcastAccessUpdate,
111
+ } from "./registries";
112
+ import {
113
+ parseMessage,
114
+ serializeMessage,
115
+ createErrorMessage,
116
+ isMachineDataMessage,
117
+ isClientDataMessage,
118
+ isClientHandshakeMessage,
119
+ type ProtocolMessage,
120
+ type RegisterMachineMessage,
121
+ type RegisterInviteMessage,
122
+ type AuthorizeClientMessage,
123
+ type RevokeClientMessage,
124
+ type ListMachinesMessage,
125
+ type ConnectWithInviteMessage,
126
+ type ConnectToMachineMessage,
127
+ type AddGlobalAccessMessage,
128
+ type RemoveGlobalAccessMessage,
129
+ type AccessListMessage,
130
+ } from "./protocol";
131
+
132
+ /**
133
+ * Generate a unique connection ID using cryptographically secure randomness
134
+ *
135
+ * Security: Uses crypto.randomBytes instead of Math.random to prevent
136
+ * connection ID prediction attacks.
137
+ */
138
+ function generateConnectionId(): string {
139
+ return randomBytes(8).toString("hex");
140
+ }
141
+
142
+ /**
143
+ * Challenge timeout in milliseconds (30 seconds)
144
+ */
145
+ const CHALLENGE_TIMEOUT_MS = 30000;
146
+
147
+ /**
148
+ * Connection rate limiting (best-effort, in-memory)
149
+ */
150
+ const RATE_LIMIT_WINDOW_MS = 60_000;
151
+ const MAX_CONNECTIONS_PER_IP = 20;
152
+ const connectionRateLimits = new Map<string, { count: number; lastReset: number }>();
153
+
154
+ function getClientIp(req: Request): string {
155
+ const forwarded = req.headers.get("x-forwarded-for");
156
+ if (forwarded) {
157
+ return forwarded.split(",")[0]?.trim() || "unknown";
158
+ }
159
+ const cfConnecting = req.headers.get("cf-connecting-ip");
160
+ if (cfConnecting) {
161
+ return cfConnecting.trim();
162
+ }
163
+ const realIp = req.headers.get("x-real-ip");
164
+ if (realIp) {
165
+ return realIp.trim();
166
+ }
167
+ return "unknown";
168
+ }
169
+
170
+ function consumeConnectionSlot(ip: string): boolean {
171
+ const now = Date.now();
172
+ const record = connectionRateLimits.get(ip);
173
+ if (!record || now - record.lastReset > RATE_LIMIT_WINDOW_MS) {
174
+ connectionRateLimits.set(ip, { count: 1, lastReset: now });
175
+ return true;
176
+ }
177
+ if (record.count >= MAX_CONNECTIONS_PER_IP) {
178
+ return false;
179
+ }
180
+ record.count += 1;
181
+ return true;
182
+ }
183
+
184
+ function resolveAssetPath(pathname: string): string | null {
185
+ const webRoot = resolve(WEB_DIST_PATH);
186
+ const relativePath = pathname.replace(/^\/+/, "");
187
+ const resolvedPath = resolve(webRoot, relativePath);
188
+ if (!resolvedPath.startsWith(webRoot + sep)) {
189
+ return null;
190
+ }
191
+ return resolvedPath;
192
+ }
193
+
194
+ type SignedClientMessageType = "list_machines" | "connect_with_invite" | "connect_to_machine";
195
+ const SIGNED_CLIENT_MESSAGE_TYPES = new Set<SignedClientMessageType>([
196
+ "list_machines",
197
+ "connect_with_invite",
198
+ "connect_to_machine",
199
+ ]);
200
+
201
+ function isSignedClientMessageType(type: unknown): type is SignedClientMessageType {
202
+ return typeof type === "string" && SIGNED_CLIENT_MESSAGE_TYPES.has(type as SignedClientMessageType);
203
+ }
204
+
205
+ function hasSignatureFields(signature: unknown): boolean {
206
+ if (!signature || typeof signature !== "object") return false;
207
+ const sig = signature as Record<string, unknown>;
208
+ return typeof sig.sig === "string" && typeof sig.pub === "string" && typeof sig.ts === "number";
209
+ }
210
+
211
+ function rejectUnsignedClientMessage(
212
+ ws: ServerWebSocket<WebSocketData>,
213
+ rawMsg: unknown
214
+ ): boolean {
215
+ if (ws.data.role !== "client") return false;
216
+ if (!rawMsg || typeof rawMsg !== "object") return false;
217
+ const msg = rawMsg as Record<string, unknown>;
218
+ if (!isSignedClientMessageType(msg.type)) return false;
219
+ if (hasSignatureFields(msg.signature)) return false;
220
+ ws.send(serializeMessage(createErrorMessage("INVALID_SIGNATURE", "Client message signature missing or invalid")));
221
+ return true;
222
+ }
223
+
224
+ function verifyClientIdentity<T extends { clientIdentityId: string }>(
225
+ msg: SignedMessage<T>
226
+ ): T | null {
227
+ const verified = verifySignedMessage(msg);
228
+ if (!verified) {
229
+ return null;
230
+ }
231
+
232
+ const signerKey = getSignerPublicKey(msg);
233
+ if (!signerKey) {
234
+ return null;
235
+ }
236
+
237
+ let derivedId: string;
238
+ try {
239
+ derivedId = deriveIdentityId(signerKey);
240
+ } catch {
241
+ return null;
242
+ }
243
+
244
+ if (derivedId !== verified.clientIdentityId) {
245
+ return null;
246
+ }
247
+
248
+ return verified;
249
+ }
250
+
251
+ interface RelayServerState {
252
+ clientConnections: Map<string, ServerWebSocket<WebSocketData>>;
253
+ machineClients: Map<string, Set<string>>;
254
+ pendingChallenges: Map<string, { nonce: Uint8Array; timestamp: number }>;
255
+ preAuthorizedMachines: Set<string>;
256
+ signRelayMessage: <T extends object>(msg: T) => T;
257
+ }
258
+
259
+ /**
260
+ * Set up a client connection to a machine
261
+ * Tracks the connection and updates machineClients map
262
+ */
263
+ function setupClientConnection(
264
+ state: RelayServerState,
265
+ machineId: string,
266
+ connectionId: string,
267
+ ws: ServerWebSocket<WebSocketData>,
268
+ clientIdentityId: string
269
+ ): void {
270
+ ws.data.machineId = machineId;
271
+ ws.data.clientIdentityId = clientIdentityId;
272
+
273
+ // Track client connection
274
+ state.clientConnections.set(connectionId, ws);
275
+
276
+ let clients = state.machineClients.get(machineId);
277
+ if (!clients) {
278
+ clients = new Set();
279
+ state.machineClients.set(machineId, clients);
280
+ }
281
+ clients.add(connectionId);
282
+ }
283
+
284
+ /**
285
+ * Create the relay server
286
+ */
287
+ export function createRelayServer(config: RelayConfig): Server<WebSocketData> {
288
+ const { port, bind = "0.0.0.0", hostname, identity } = config;
289
+ const disableRateLimit = config.disableRateLimit === true;
290
+
291
+ // NOTE: This file can be used in Bun tests which run files in parallel.
292
+ // Keep mutable state per-server instance (not module-global) so multiple
293
+ // relay servers can coexist in the same process without interfering.
294
+ const relayIdentity: RelayIdentity = identity;
295
+ const fingerprint = formatRelayFingerprint(identity.signingPublicKey);
296
+ console.log(`[relay] Using identity: ${fingerprint}${identity.label ? ` (${identity.label})` : ""}`);
297
+
298
+ // Store pre-authorized machines (for ephemeral local relays)
299
+ const preAuthorizedMachines: Set<string> = config.preAuthorizedMachines instanceof Set
300
+ ? config.preAuthorizedMachines
301
+ : new Set(config.preAuthorizedMachines || []);
302
+ if (preAuthorizedMachines.size > 0) {
303
+ console.log(`[relay] Pre-authorized ${preAuthorizedMachines.size} machine(s)`);
304
+ }
305
+
306
+ /**
307
+ * Client connections by connectionId (for routing machine → client)
308
+ */
309
+ const clientConnections = new Map<string, ServerWebSocket<WebSocketData>>();
310
+
311
+ /**
312
+ * Track which clients are connected to which machine
313
+ * machineId → Set<connectionId>
314
+ */
315
+ const machineClients = new Map<string, Set<string>>();
316
+
317
+ /**
318
+ * Track pending identity challenges for machine connections
319
+ * connectionId → { nonce, timestamp }
320
+ */
321
+ interface PendingChallenge {
322
+ nonce: Uint8Array;
323
+ timestamp: number;
324
+ }
325
+ const pendingChallenges = new Map<string, PendingChallenge>();
326
+
327
+ /**
328
+ * Sign a message with the relay's private key
329
+ * Returns the message with signature
330
+ */
331
+ const signRelayMessage = <T extends object>(msg: T): T => {
332
+ const pubKeyBytes = new Uint8Array(Buffer.from(relayIdentity.signingPublicKey, "base64"));
333
+ return signMessage(msg, relayIdentity.signingPrivateKey, pubKeyBytes);
334
+ };
335
+
336
+ const state: RelayServerState = {
337
+ clientConnections,
338
+ machineClients,
339
+ pendingChallenges,
340
+ preAuthorizedMachines,
341
+ signRelayMessage,
342
+ };
343
+
344
+ const server = Bun.serve<WebSocketData>({
345
+ port,
346
+ hostname: bind,
347
+
348
+ async fetch(req, server) {
349
+ const url = new URL(req.url);
350
+
351
+ // Check Host header if hostname is specified
352
+ if (hostname) {
353
+ const hostHeader = req.headers.get("host");
354
+ const host = hostHeader?.split(":")[0]; // Remove port
355
+ console.log(`[relay] Request: ${url.pathname} Host: ${hostHeader} -> ${host} (expected: ${hostname})`);
356
+ if (host !== hostname) {
357
+ console.log(`[relay] Rejecting request - hostname mismatch`);
358
+ return new Response("Not found", { status: 404 });
359
+ }
360
+ }
361
+
362
+ // Health check
363
+ if (url.pathname === "/health") {
364
+ const stats = getRegistryStats();
365
+ const clientCount = clientConnections.size;
366
+ return Response.json({
367
+ status: "ok",
368
+ ...stats,
369
+ connectedClients: clientCount,
370
+ });
371
+ }
372
+
373
+ // WebSocket upgrade
374
+ // - Machines and clients connect freely
375
+ // - Machine authentication happens via challenge-response during registration
376
+ // - Client authorization happens via X3DH handshake with machine
377
+ if (url.pathname === "/ws") {
378
+ if (!disableRateLimit) {
379
+ const clientIp = getClientIp(req);
380
+ if (!consumeConnectionSlot(clientIp)) {
381
+ return new Response("Too many connections", { status: 429 });
382
+ }
383
+ }
384
+
385
+ const role = url.searchParams.get("role") as "machine" | "client";
386
+
387
+ if (!role || !["machine", "client"].includes(role)) {
388
+ return new Response("Invalid role", { status: 400 });
389
+ }
390
+
391
+ const wsData: WebSocketData = {
392
+ machineId: "", // Set later by protocol messages
393
+ role,
394
+ connectionId: generateConnectionId(),
395
+ };
396
+
397
+ // Upgrade to WebSocket
398
+ const upgraded = server.upgrade(req, { data: wsData });
399
+
400
+ if (!upgraded) {
401
+ return new Response("WebSocket upgrade failed", { status: 500 });
402
+ }
403
+
404
+ return undefined;
405
+ }
406
+
407
+ // Serve web terminal UI (embedded or from filesystem)
408
+ if (url.pathname === "/" || url.pathname === "/index.html" || url.pathname.startsWith("/assets/") || url.pathname === "/vite.svg") {
409
+ const response = await serveStaticFile(url.pathname);
410
+ if (response) return response;
411
+ }
412
+
413
+ return new Response("Not Found", { status: 404 });
414
+ },
415
+
416
+ websocket: {
417
+ open(ws) {
418
+ const { role, connectionId } = ws.data;
419
+ console.log(`[ws] ${role} ${connectionId} connected`);
420
+
421
+ // Send relay_identity message to machines (includes challenge nonce)
422
+ if (role === "machine") {
423
+ const nonce = randomBytes(32);
424
+ const relayIdMsg = {
425
+ type: "relay_identity" as const,
426
+ publicKey: relayIdentity.signingPublicKey,
427
+ fingerprint: formatRelayFingerprint(relayIdentity.signingPublicKey),
428
+ label: relayIdentity.label,
429
+ challenge: nonce.toString("base64"),
430
+ };
431
+
432
+ // Store pending challenge
433
+ pendingChallenges.set(connectionId, {
434
+ nonce,
435
+ timestamp: Date.now(),
436
+ });
437
+
438
+ ws.send(serializeMessage(relayIdMsg));
439
+ console.log(`[ws] Sent relay_identity to machine ${connectionId}`);
440
+ }
441
+ },
442
+
443
+ message(ws, message) {
444
+ // Try to parse as protocol message (JSON)
445
+ const msgStr = typeof message === "string"
446
+ ? message
447
+ : new TextDecoder().decode(message instanceof ArrayBuffer ? message : message);
448
+
449
+ let rawMsg: unknown = null;
450
+ // Handle ping/pong for keepalive FIRST (before protocol parsing)
451
+ // These are simple keepalive messages, not protocol messages
452
+ try {
453
+ rawMsg = JSON.parse(msgStr);
454
+ if (rawMsg && typeof rawMsg === "object" && (rawMsg as { type?: string }).type === "ping") {
455
+ ws.send(JSON.stringify({ type: "pong", timestamp: Date.now() }));
456
+ return;
457
+ }
458
+ } catch {
459
+ // Not valid JSON - continue with normal handling
460
+ }
461
+
462
+ const parsed = parseMessage(msgStr);
463
+ if (!parsed && rejectUnsignedClientMessage(ws, rawMsg)) {
464
+ return;
465
+ }
466
+
467
+ // Route data and handshake messages between client and machine
468
+ // All other message types are protocol messages handled by the relay
469
+ if (parsed && parsed.type !== "data" && parsed.type !== "handshake") {
470
+ // Handle protocol message
471
+ handleProtocolMessage(state, ws, parsed);
472
+ return;
473
+ }
474
+
475
+ // Handle data/handshake message - route based on role and connectionId
476
+ handleDataMessage(state, ws, message);
477
+ },
478
+
479
+ close(ws, code, reason) {
480
+ const { machineId, role, connectionId } = ws.data;
481
+ console.log(
482
+ `[ws] ${role} ${connectionId} disconnected (${code}: ${reason})`
483
+ );
484
+
485
+ // Clean up pending challenge if any
486
+ pendingChallenges.delete(connectionId);
487
+
488
+ if (role === "machine" && machineId) {
489
+ // Mark machine as offline
490
+ setMachineConnection(machineId, null);
491
+
492
+ // Notify connected clients that machine is offline
493
+ const clients = machineClients.get(machineId);
494
+ if (clients) {
495
+ for (const clientConnId of clients) {
496
+ const clientWs = clientConnections.get(clientConnId);
497
+ if (clientWs) {
498
+ clientWs.send(serializeMessage({
499
+ type: "connection_failed",
500
+ reason: "Machine disconnected",
501
+ }));
502
+ clientWs.close(1000, "Machine disconnected");
503
+ }
504
+ }
505
+ machineClients.delete(machineId);
506
+ }
507
+ } else if (role === "client") {
508
+ // Remove from client connections
509
+ clientConnections.delete(connectionId);
510
+
511
+ // Remove from machine's client set
512
+ if (machineId) {
513
+ const clients = machineClients.get(machineId);
514
+ if (clients) {
515
+ clients.delete(connectionId);
516
+ }
517
+
518
+ // Notify machine of client disconnect
519
+ const machine = getMachine(machineId);
520
+ if (machine?.ws) {
521
+ machine.ws.send(serializeMessage({
522
+ type: "client_disconnected",
523
+ connectionId,
524
+ reason: reason || "Client disconnected",
525
+ }));
526
+ }
527
+ }
528
+ }
529
+ },
530
+
531
+ drain(_ws) {
532
+ // Called when the socket is ready for more data
533
+ },
534
+ },
535
+ });
536
+
537
+ console.log(`[relay] Listening on ${bind}:${port}${hostname ? ` (serving ${hostname})` : ""}`);
538
+ return server;
539
+ }
540
+
541
+ /**
542
+ * Handle protocol messages
543
+ */
544
+ function handleProtocolMessage(
545
+ state: RelayServerState,
546
+ ws: ServerWebSocket<WebSocketData>,
547
+ msg: ProtocolMessage
548
+ ): void {
549
+ const { role, connectionId } = ws.data;
550
+
551
+ switch (msg.type) {
552
+ // ========== Machine Messages ==========
553
+
554
+ case "register_machine": {
555
+ if (role !== "machine") {
556
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can register")));
557
+ return;
558
+ }
559
+
560
+ const regMsg = msg as RegisterMachineMessage;
561
+
562
+ // Get pending challenge for this connection
563
+ const pending = state.pendingChallenges.get(connectionId);
564
+ if (!pending) {
565
+ ws.send(serializeMessage(createErrorMessage("INVALID_STATE", "No pending challenge - reconnect required")));
566
+ return;
567
+ }
568
+
569
+ // Check challenge timeout
570
+ if (Date.now() - pending.timestamp > CHALLENGE_TIMEOUT_MS) {
571
+ state.pendingChallenges.delete(connectionId);
572
+ ws.send(serializeMessage(createErrorMessage("EXPIRED", "Challenge expired - reconnect required")));
573
+ ws.close();
574
+ return;
575
+ }
576
+
577
+ // Verify challenge response signature
578
+ if (!regMsg.challengeResponse) {
579
+ ws.send(serializeMessage(createErrorMessage("INVALID_REQUEST", "Challenge response required")));
580
+ return;
581
+ }
582
+
583
+ try {
584
+ const signatureBytes = new Uint8Array(Buffer.from(regMsg.challengeResponse, "base64"));
585
+ const pubkeyBytes = new Uint8Array(Buffer.from(regMsg.signingKey, "base64"));
586
+
587
+ if (!ed25519.verify(signatureBytes, pending.nonce, pubkeyBytes)) {
588
+ console.warn(`[relay] Challenge verification failed for ${connectionId}`);
589
+ state.pendingChallenges.delete(connectionId);
590
+ ws.send(serializeMessage(createErrorMessage("INVALID_SIGNATURE", "Challenge response signature invalid")));
591
+ ws.close();
592
+ return;
593
+ }
594
+ } catch (err) {
595
+ console.error(`[relay] Challenge verification error:`, err);
596
+ state.pendingChallenges.delete(connectionId);
597
+ ws.send(serializeMessage(createErrorMessage("ERROR", "Challenge verification failed")));
598
+ ws.close();
599
+ return;
600
+ }
601
+
602
+ // Challenge verified - clean up pending challenge
603
+ state.pendingChallenges.delete(connectionId);
604
+
605
+ // Check if machine is authorized to connect to this relay
606
+ // Check both on-disk list and pre-authorized set (for ephemeral local relays)
607
+ const isPreAuthorized = state.preAuthorizedMachines.has(regMsg.signingKey);
608
+ if (!isPreAuthorized && !isAuthorized(regMsg.signingKey)) {
609
+ console.warn(`[relay] Machine not authorized: ${regMsg.machineId} (signingKey not in authorized list)`);
610
+ ws.send(serializeMessage(createErrorMessage("UNAUTHORIZED", "Machine not authorized for this relay")));
611
+ ws.close();
612
+ return;
613
+ }
614
+
615
+ // Get authorized machine info for account tracking
616
+ const authorizedMachine = getAuthorizedMachine(regMsg.signingKey);
617
+ const accountId = authorizedMachine?.fingerprint || regMsg.machineId;
618
+
619
+ // Register the machine (with ownership verification for re-registration)
620
+ const result = registerMachine(
621
+ regMsg.machineId,
622
+ accountId,
623
+ regMsg.signingKey,
624
+ regMsg.keyExchangeKey,
625
+ ws,
626
+ regMsg.label
627
+ );
628
+
629
+ // Handle registration failure (e.g., machine hijacking attempt)
630
+ if (!result.success) {
631
+ console.warn(`[relay] Machine registration rejected: ${result.error} (machineId=${regMsg.machineId})`);
632
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", result.error)));
633
+ return;
634
+ }
635
+
636
+ // Update ws data
637
+ ws.data.machineId = regMsg.machineId;
638
+ ws.data.accountId = accountId;
639
+
640
+ console.log(`[relay] Machine ${regMsg.machineId} registered (authorized: ${authorizedMachine?.label || authorizedMachine?.fingerprint || "unknown"})`);
641
+
642
+ ws.send(serializeMessage({
643
+ type: "registered",
644
+ machineId: regMsg.machineId,
645
+ }));
646
+
647
+ // Send global access list to newly registered machine (signed)
648
+ const accessEntries = getEffectiveAccessList(accountId, regMsg.machineId);
649
+ if (accessEntries.length > 0) {
650
+ const accessListMsg: AccessListMessage = {
651
+ type: "access_list",
652
+ entries: accessEntries.map(e => ({
653
+ clientIdentityId: e.clientIdentityId,
654
+ signingKey: e.signingKey,
655
+ keyExchangeKey: e.keyExchangeKey,
656
+ label: e.label,
657
+ accessType: e.accessType,
658
+ sessionId: e.sessionId,
659
+ grantedAt: e.grantedAt,
660
+ })),
661
+ protocolVersion: PROTOCOL_VERSION,
662
+ };
663
+ // Sign the access_list message
664
+ const signedMsg = state.signRelayMessage(accessListMsg);
665
+ ws.send(serializeMessage(signedMsg));
666
+ console.log(`[relay] Sent ${accessEntries.length} access entries to machine ${regMsg.machineId} (signed)`);
667
+ }
668
+ break;
669
+ }
670
+
671
+ // Legacy challenge_response - kept for backwards compatibility
672
+ case "challenge_response": {
673
+ // In new flow, challenge response is part of register_machine message
674
+ // This is kept for backwards compatibility with older clients
675
+ ws.send(serializeMessage(createErrorMessage("DEPRECATED", "Use register_machine with challengeResponse field")));
676
+ return;
677
+ }
678
+
679
+ case "register_invite": {
680
+ if (role !== "machine") {
681
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can register invites")));
682
+ return;
683
+ }
684
+
685
+ const invMsg = msg as RegisterInviteMessage;
686
+
687
+ // Verify machine is registered and owned by this connection
688
+ const machine = getMachine(invMsg.machineId);
689
+ if (!machine || machine.accountId !== ws.data.accountId) {
690
+ ws.send(serializeMessage(createErrorMessage("NOT_FOUND", "Machine not registered or unauthorized")));
691
+ return;
692
+ }
693
+
694
+ // Register the invite
695
+ registerInvite(
696
+ invMsg.inviteId,
697
+ invMsg.machineId,
698
+ invMsg.expiresAt,
699
+ invMsg.maxUses
700
+ );
701
+
702
+ console.log(`[relay] Invite ${invMsg.inviteId} registered for machine ${invMsg.machineId}`);
703
+
704
+ ws.send(serializeMessage({
705
+ type: "registered",
706
+ machineId: invMsg.machineId,
707
+ }));
708
+ break;
709
+ }
710
+
711
+ case "authorize_client": {
712
+ if (role !== "machine") {
713
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can authorize clients")));
714
+ return;
715
+ }
716
+
717
+ const authMsg = msg as AuthorizeClientMessage;
718
+
719
+ // Verify machine is registered and owned by this connection
720
+ const machine = getMachine(authMsg.machineId);
721
+ if (!machine || machine.accountId !== ws.data.accountId) {
722
+ ws.send(serializeMessage(createErrorMessage("NOT_FOUND", "Machine not registered or unauthorized")));
723
+ return;
724
+ }
725
+
726
+ // Authorize the client
727
+ authorizeClient(
728
+ authMsg.machineId,
729
+ authMsg.clientIdentityId,
730
+ authMsg.signingKey,
731
+ authMsg.keyExchangeKey,
732
+ authMsg.accessType,
733
+ authMsg.sessionId
734
+ );
735
+
736
+ console.log(`[relay] Client ${authMsg.clientIdentityId} authorized for machine ${authMsg.machineId}`);
737
+
738
+ ws.send(serializeMessage({
739
+ type: "client_authorized",
740
+ clientIdentityId: authMsg.clientIdentityId,
741
+ }));
742
+ break;
743
+ }
744
+
745
+ case "revoke_client": {
746
+ if (role !== "machine") {
747
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can revoke clients")));
748
+ return;
749
+ }
750
+
751
+ const revokeMsg = msg as RevokeClientMessage;
752
+
753
+ // Verify machine is registered and owned by this connection
754
+ const machine = getMachine(revokeMsg.machineId);
755
+ if (!machine || machine.accountId !== ws.data.accountId) {
756
+ ws.send(serializeMessage(createErrorMessage("NOT_FOUND", "Machine not registered or unauthorized")));
757
+ return;
758
+ }
759
+
760
+ // Revoke client authorization
761
+ revokeClientAuthorization(revokeMsg.machineId, revokeMsg.clientIdentityId);
762
+
763
+ console.log(`[relay] Client ${revokeMsg.clientIdentityId} revoked from machine ${revokeMsg.machineId}`);
764
+
765
+ ws.send(serializeMessage({
766
+ type: "client_revoked",
767
+ clientIdentityId: revokeMsg.clientIdentityId,
768
+ }));
769
+ break;
770
+ }
771
+
772
+ case "add_global_access": {
773
+ if (role !== "machine") {
774
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can add global access")));
775
+ return;
776
+ }
777
+
778
+ if (!ws.data.accountId) {
779
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Authentication required")));
780
+ return;
781
+ }
782
+
783
+ const addMsg = msg as AddGlobalAccessMessage;
784
+
785
+ // Add to global access list
786
+ const entry = addGlobalAccess(ws.data.accountId, {
787
+ clientIdentityId: addMsg.clientIdentityId,
788
+ signingKey: addMsg.signingKey,
789
+ keyExchangeKey: addMsg.keyExchangeKey,
790
+ label: addMsg.label,
791
+ accessType: addMsg.accessType,
792
+ sessionId: addMsg.sessionId,
793
+ machineIds: addMsg.machineIds,
794
+ });
795
+
796
+ console.log(`[relay] Global access added: ${addMsg.clientIdentityId} by ${ws.data.accountId}`);
797
+
798
+ // Broadcast to all machines owned by this account (signed)
799
+ broadcastAccessUpdate(ws.data.accountId, [entry], [], state.signRelayMessage);
800
+
801
+ // Also authorize for per-machine tracking
802
+ const machineId = ws.data.machineId;
803
+ if (machineId) {
804
+ authorizeClient(
805
+ machineId,
806
+ addMsg.clientIdentityId,
807
+ addMsg.signingKey,
808
+ addMsg.keyExchangeKey,
809
+ addMsg.accessType,
810
+ addMsg.sessionId
811
+ );
812
+ }
813
+
814
+ ws.send(serializeMessage({
815
+ type: "client_authorized",
816
+ clientIdentityId: addMsg.clientIdentityId,
817
+ }));
818
+ break;
819
+ }
820
+
821
+ case "remove_global_access": {
822
+ if (role !== "machine") {
823
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can remove global access")));
824
+ return;
825
+ }
826
+
827
+ if (!ws.data.accountId) {
828
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Authentication required")));
829
+ return;
830
+ }
831
+
832
+ const removeMsg = msg as RemoveGlobalAccessMessage;
833
+
834
+ // Remove from global access list
835
+ const removed = removeGlobalAccess(ws.data.accountId, removeMsg.clientIdentityId);
836
+
837
+ if (removed) {
838
+ console.log(`[relay] Global access removed: ${removeMsg.clientIdentityId} by ${ws.data.accountId}`);
839
+
840
+ // Broadcast to all machines owned by this account (signed)
841
+ broadcastAccessUpdate(ws.data.accountId, [], [removeMsg.clientIdentityId], state.signRelayMessage);
842
+ }
843
+
844
+ ws.send(serializeMessage({
845
+ type: "client_revoked",
846
+ clientIdentityId: removeMsg.clientIdentityId,
847
+ }));
848
+ break;
849
+ }
850
+
851
+ // ========== Client Messages ==========
852
+
853
+ case "list_machines": {
854
+ if (role !== "client") {
855
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only clients can list machines")));
856
+ return;
857
+ }
858
+
859
+ const listMsg = msg as ListMachinesMessage;
860
+ const verified = verifyClientIdentity(listMsg);
861
+ if (!verified) {
862
+ ws.send(serializeMessage(createErrorMessage("INVALID_SIGNATURE", "Client message signature invalid")));
863
+ return;
864
+ }
865
+
866
+ if (ws.data.clientIdentityId && ws.data.clientIdentityId !== verified.clientIdentityId) {
867
+ ws.send(serializeMessage(createErrorMessage("IDENTITY_MISMATCH", "Client identity does not match connection")));
868
+ return;
869
+ }
870
+ ws.data.clientIdentityId = verified.clientIdentityId;
871
+
872
+ // Get only AUTHORIZED machines for this client
873
+ // Client must be in the machine's access list to see it
874
+ const allMachines = getAllMachinesWithAuthStatus(verified.clientIdentityId);
875
+ const authorizedMachines = allMachines.filter(m => m.isAuthorized);
876
+
877
+ ws.send(serializeMessage({
878
+ type: "machine_list",
879
+ machines: authorizedMachines.map(({ machineId, machine, isAuthorized, accessType, sessionId }) => ({
880
+ machineId,
881
+ label: machine.label,
882
+ online: machine.ws !== null,
883
+ isAuthorized,
884
+ accessType,
885
+ sessionId,
886
+ lastConnectedAt: machine.lastConnectedAt,
887
+ })),
888
+ }));
889
+ break;
890
+ }
891
+
892
+ case "connect_with_invite": {
893
+ if (role !== "client") {
894
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only clients can connect with invites")));
895
+ return;
896
+ }
897
+
898
+ const inviteMsg = msg as ConnectWithInviteMessage;
899
+ const verified = verifyClientIdentity(inviteMsg);
900
+ if (!verified) {
901
+ ws.send(serializeMessage(createErrorMessage("INVALID_SIGNATURE", "Client message signature invalid")));
902
+ return;
903
+ }
904
+
905
+ if (ws.data.clientIdentityId && ws.data.clientIdentityId !== verified.clientIdentityId) {
906
+ ws.send(serializeMessage(createErrorMessage("IDENTITY_MISMATCH", "Client identity does not match connection")));
907
+ return;
908
+ }
909
+
910
+ // Look up invite
911
+ const invite = getInvite(inviteMsg.inviteId);
912
+ if (!invite) {
913
+ ws.send(serializeMessage(createErrorMessage("NOT_FOUND", "Invite not found")));
914
+ return;
915
+ }
916
+
917
+ if (!isInviteValid(inviteMsg.inviteId)) {
918
+ ws.send(serializeMessage(createErrorMessage("INVALID", "Invite expired or exhausted")));
919
+ return;
920
+ }
921
+
922
+ // Check machine is online
923
+ const machine = getMachine(invite.machineId);
924
+ if (!machine || !machine.ws) {
925
+ ws.send(serializeMessage(createErrorMessage("OFFLINE", "Machine is offline")));
926
+ return;
927
+ }
928
+
929
+ // Use the invite (decrements use count)
930
+ useInvite(inviteMsg.inviteId);
931
+
932
+ // Set up client connection tracking
933
+ setupClientConnection(state, invite.machineId, connectionId, ws, verified.clientIdentityId);
934
+
935
+ // Notify machine of new client
936
+ machine.ws.send(serializeMessage({
937
+ type: "client_connected",
938
+ connectionId,
939
+ clientIdentityId: verified.clientIdentityId,
940
+ viaInvite: inviteMsg.inviteId,
941
+ }));
942
+
943
+ // Send connection established to client
944
+ ws.send(serializeMessage({
945
+ type: "connection_established",
946
+ machineId: invite.machineId,
947
+ connectionId,
948
+ }));
949
+
950
+ console.log(`[relay] Client ${verified.clientIdentityId} connected to ${invite.machineId} via invite`);
951
+ break;
952
+ }
953
+
954
+ case "connect_to_machine": {
955
+ if (role !== "client") {
956
+ ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only clients can connect to machines")));
957
+ return;
958
+ }
959
+
960
+ const connectMsg = msg as ConnectToMachineMessage;
961
+ const verified = verifyClientIdentity(connectMsg);
962
+ if (!verified) {
963
+ ws.send(serializeMessage(createErrorMessage("INVALID_SIGNATURE", "Client message signature invalid")));
964
+ return;
965
+ }
966
+
967
+ if (ws.data.clientIdentityId && ws.data.clientIdentityId !== verified.clientIdentityId) {
968
+ ws.send(serializeMessage(createErrorMessage("IDENTITY_MISMATCH", "Client identity does not match connection")));
969
+ return;
970
+ }
971
+
972
+ // Check machine exists
973
+ const machine = getMachine(connectMsg.machineId);
974
+ if (!machine) {
975
+ ws.send(serializeMessage(createErrorMessage("NOT_FOUND", "Machine not found")));
976
+ return;
977
+ }
978
+
979
+ // Check machine is online
980
+ if (!machine.ws) {
981
+ ws.send(serializeMessage(createErrorMessage("OFFLINE", "Machine is offline")));
982
+ return;
983
+ }
984
+
985
+ // NOTE: We don't check isClientAuthorized here anymore.
986
+ // Authorization happens via X3DH handshake - the machine will
987
+ // verify the client's identity and reject if not on ACL.
988
+
989
+ // Set up client connection tracking
990
+ setupClientConnection(state, connectMsg.machineId, connectionId, ws, verified.clientIdentityId);
991
+
992
+ // Notify machine of new client
993
+ machine.ws.send(serializeMessage({
994
+ type: "client_connected",
995
+ connectionId,
996
+ clientIdentityId: verified.clientIdentityId,
997
+ }));
998
+
999
+ // Send connection established to client
1000
+ ws.send(serializeMessage({
1001
+ type: "connection_established",
1002
+ machineId: connectMsg.machineId,
1003
+ connectionId,
1004
+ }));
1005
+
1006
+ console.log(`[relay] Client ${verified.clientIdentityId} connected to ${connectMsg.machineId} directly`);
1007
+ break;
1008
+ }
1009
+
1010
+ default: {
1011
+ // Log unhandled message types (data/handshake are handled separately)
1012
+ const unhandled = msg as { type: string };
1013
+ console.log(`[relay] Unknown message type: ${unhandled.type}`);
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ /**
1019
+ * Handle data/handshake messages - route between machine and clients
1020
+ */
1021
+ function handleDataMessage(
1022
+ state: RelayServerState,
1023
+ ws: ServerWebSocket<WebSocketData>,
1024
+ message: string | ArrayBuffer | Uint8Array
1025
+ ): void {
1026
+ const { role, machineId, connectionId } = ws.data;
1027
+
1028
+ if (!machineId) {
1029
+ console.log(`[relay] Data from unconnected ${role} ${connectionId}`);
1030
+ return;
1031
+ }
1032
+
1033
+ const msgStr = typeof message === "string"
1034
+ ? message
1035
+ : new TextDecoder().decode(message instanceof ArrayBuffer ? message : message);
1036
+
1037
+ const parsed = parseMessage(msgStr);
1038
+
1039
+ if (role === "machine") {
1040
+ // Machine sending data - parse to get target connectionId
1041
+ if (!parsed || !isMachineDataMessage(parsed)) {
1042
+ console.log(`[relay] Invalid data message from machine`);
1043
+ return;
1044
+ }
1045
+
1046
+ // Route to specific client by connectionId (now properly typed)
1047
+ const targetConnId = parsed.connectionId;
1048
+ const clientWs = state.clientConnections.get(targetConnId);
1049
+
1050
+ if (clientWs) {
1051
+ // Forward data to client (unwrap the connectionId since client knows their own)
1052
+ clientWs.send(serializeMessage({
1053
+ type: "data",
1054
+ data: parsed.data,
1055
+ }));
1056
+ } else {
1057
+ console.log(`[relay] Target client ${targetConnId} not found`);
1058
+ }
1059
+ } else {
1060
+ // Client sending data/handshake - wrap with connectionId for machine
1061
+ const machine = getMachine(machineId);
1062
+ if (!machine || !machine.ws) {
1063
+ console.log(`[relay] Machine ${machineId} not connected`);
1064
+ return;
1065
+ }
1066
+
1067
+ if (!parsed) {
1068
+ console.log(`[relay] Invalid message from client`);
1069
+ return;
1070
+ }
1071
+
1072
+ if (isClientHandshakeMessage(parsed)) {
1073
+ // Wrap handshake message in data envelope for machine
1074
+ // The machine handler will decode the base64 and process as handshake
1075
+ const handshakeJson = JSON.stringify(parsed);
1076
+ machine.ws.send(serializeMessage({
1077
+ type: "data",
1078
+ connectionId,
1079
+ data: Buffer.from(handshakeJson).toString("base64"),
1080
+ }));
1081
+ } else if (isClientDataMessage(parsed)) {
1082
+ // Forward data message with connectionId (now properly typed)
1083
+ machine.ws.send(serializeMessage({
1084
+ type: "data",
1085
+ connectionId,
1086
+ data: parsed.data,
1087
+ }));
1088
+ } else {
1089
+ console.log(`[relay] Invalid message from client: ${parsed.type}`);
1090
+ }
1091
+ }
1092
+ }