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,1819 @@
1
+ # gitspace.sh Platform Specification
2
+
3
+ > **Complete specification for the gitspace.sh hosting platform**
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ gitspace.sh is a lightweight platform that gives developers instant hosting via Cloudflare Tunnels. Users reserve a subdomain, get a tunnel token, and `gssh serve` handles the rest.
10
+
11
+ **Core Principles**:
12
+ - **Zero infrastructure for us** - Users run their own tunnels
13
+ - **Instant setup** - Reserve subdomain, start serving
14
+ - **Peer relay model** - One machine with subdomain can relay for others
15
+ - **E2E encryption** - Terminal access remains encrypted
16
+
17
+ ---
18
+
19
+ ## Architecture
20
+
21
+ ```
22
+ ┌─────────────────────────────────────────────────────────────────────────────┐
23
+ │ GITSPACE.SH PLATFORM │
24
+ ├─────────────────────────────────────────────────────────────────────────────┤
25
+ │ │
26
+ │ CLOUDFLARE (managed by gitspace.sh) │
27
+ │ ──────────────────────────────────── │
28
+ │ │
29
+ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
30
+ │ │ Workers │ │ Pages │ │ D1 │ │ KV │ │
31
+ │ │ (API) │ │ (Portal) │ │ (Database) │ │ (Sessions) │ │
32
+ │ │ │ │ │ │ │ │ │ │
33
+ │ │ api. │ │ gitspace.sh │ │ users │ │ sessions │ │
34
+ │ │ gitspace.sh │ │ │ │ subdomains │ │ (TTL: 7d) │ │
35
+ │ │ │ │ │ │ tokens │ │ │ │
36
+ │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
37
+ │ │ │ │
38
+ │ │ Cloudflare Tunnel API │ │
39
+ │ └────────────────┬───────────────────┘ │
40
+ │ │ │
41
+ │ ▼ │
42
+ │ ┌─────────────────────────────────────────────────────────────────────┐ │
43
+ │ │ DNS: gitspace.sh │ │
44
+ │ │ ├── brad.gitspace.sh → tunnel-brad-xxx.cfargotunnel.com │ │
45
+ │ │ ├── *.brad.gitspace.sh → tunnel-brad-xxx.cfargotunnel.com │ │
46
+ │ │ ├── sarah.gitspace.sh → tunnel-sarah-xxx.cfargotunnel.com │ │
47
+ │ │ └── *.sarah.gitspace.sh → tunnel-sarah-xxx.cfargotunnel.com │ │
48
+ │ │ │ │
49
+ │ │ SSL: Total TLS ($10/mo) - covers *.*.gitspace.sh │ │
50
+ │ └─────────────────────────────────────────────────────────────────────┘ │
51
+ │ │
52
+ │ ═══════════════════════════════════════════════════════════════════════ │
53
+ │ │
54
+ │ USER'S MACHINES (user-owned, user-operated) │
55
+ │ ─────────────────────────────────────────── │
56
+ │ │
57
+ │ Brad's MacBook (PRIMARY - has subdomain) │
58
+ │ ┌─────────────────────────────────────────────────────────────────────┐ │
59
+ │ │ gssh serve │ │
60
+ │ │ ├── cloudflared (tunnel: brad.gitspace.sh) │ │
61
+ │ │ ├── Local HTTP server (:8080) │ │
62
+ │ │ │ ├── HTTP routes → services/Lima VMs │ │
63
+ │ │ │ └── WebSocket /ws → terminal (E2E encrypted) │ │
64
+ │ │ ├── Embedded relay (accepts connections from other machines) │ │
65
+ │ │ └── tmux-lite server (PTY sessions) │ │
66
+ │ └─────────────────────────────────────────────────────────────────────┘ │
67
+ │ ▲ ▲ │
68
+ │ │ WebSocket │ WebSocket │
69
+ │ │ │ │
70
+ │ Brad's Work Desktop Brad's Home Server │
71
+ │ (SECONDARY - no subdomain) (SECONDARY - no subdomain) │
72
+ │ ┌───────────────────┐ ┌───────────────────┐ │
73
+ │ │ gssh serve │ │ gssh serve │ │
74
+ │ │ --relay brad. │ │ --relay brad. │ │
75
+ │ │ gitspace.sh │ │ gitspace.sh │ │
76
+ │ └───────────────────┘ └───────────────────┘ │
77
+ │ │
78
+ └─────────────────────────────────────────────────────────────────────────────┘
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Authentication
84
+
85
+ gitspace.sh uses **GitHub as the identity provider**. Users can authenticate via:
86
+ - **Portal**: GitHub OAuth (redirect-based) for browser access
87
+ - **CLI**: GitHub Device Flow for terminal access
88
+
89
+ Both methods create/access the **same account** (keyed by GitHub user ID).
90
+
91
+ ```
92
+ ┌─────────────────────────────────────────────────────────────────────────────┐
93
+ │ TWO ENTRY POINTS, ONE ACCOUNT │
94
+ ├─────────────────────────────────────────────────────────────────────────────┤
95
+ │ │
96
+ │ PORTAL (Browser) CLI (Terminal) │
97
+ │ ──────────────── ────────────── │
98
+ │ │
99
+ │ gitspace.sh $ gssh auth login │
100
+ │ ┌─────────────────────┐ │
101
+ │ │ Sign in with GitHub │ ! Code: ABCD-1234 │
102
+ │ └──────────┬──────────┘ Open github.com/login/device │
103
+ │ │ │ │
104
+ │ ▼ ▼ │
105
+ │ GitHub OAuth (redirect) GitHub Device Flow │
106
+ │ │ │ │
107
+ │ ▼ ▼ │
108
+ │ Callback with token Poll for token │
109
+ │ │ │ │
110
+ │ └───────────────┬───────────────────────┘ │
111
+ │ │ │
112
+ │ ▼ │
113
+ │ ┌───────────────────────────────────────┐ │
114
+ │ │ gitspace.sh API │ │
115
+ │ │ │ │
116
+ │ │ 1. Verify GitHub token │ │
117
+ │ │ 2. Get GitHub user ID │ │
118
+ │ │ 3. Find or create account │ │
119
+ │ │ (keyed by GitHub ID) │ │
120
+ │ │ 4. Return session/token │ │
121
+ │ └───────────────────────────────────────┘ │
122
+ │ │ │
123
+ │ ▼ │
124
+ │ ┌───────────────────────────────────────┐ │
125
+ │ │ Same account in D1: │ │
126
+ │ │ { │ │
127
+ │ │ id: "uuid", │ │
128
+ │ │ github_id: "12345", ◄──────────── │ ── Unique identifier │
129
+ │ │ github_username: "brad", │ │
130
+ │ │ email: "...", │ │
131
+ │ │ } │ │
132
+ │ └───────────────────────────────────────┘ │
133
+ │ │
134
+ └─────────────────────────────────────────────────────────────────────────────┘
135
+ ```
136
+
137
+ ---
138
+
139
+ ## User Flow
140
+
141
+ ### 1. Sign Up / Login (Portal)
142
+
143
+ ```
144
+ ┌─────────────────────────────────────────────────────────────────────────────┐
145
+ │ Browser: gitspace.sh │
146
+ ├─────────────────────────────────────────────────────────────────────────────┤
147
+ │ │
148
+ │ ┌─────────────────────────────────────────────────────────────────────┐ │
149
+ │ │ │ │
150
+ │ │ gitspace.sh │ │
151
+ │ │ │ │
152
+ │ │ Instant hosting for your dev environment │ │
153
+ │ │ │ │
154
+ │ │ ┌──────────────────────┐ │ │
155
+ │ │ │ Sign in with GitHub │ │ │
156
+ │ │ └──────────────────────┘ │ │
157
+ │ │ │ │
158
+ │ └─────────────────────────────────────────────────────────────────────┘ │
159
+ │ │
160
+ │ OAuth flow (redirect-based): │
161
+ │ 1. User clicks "Sign in with GitHub" │
162
+ │ 2. Redirects to GitHub OAuth authorize URL │
163
+ │ 3. User authorizes gitspace.sh app │
164
+ │ 4. GitHub redirects to callback with code │
165
+ │ 5. API exchanges code for token, verifies user │
166
+ │ 6. Creates/updates user in D1 (keyed by github_id) │
167
+ │ 7. Sets session cookie, redirects to dashboard │
168
+ │ │
169
+ └─────────────────────────────────────────────────────────────────────────────┘
170
+ ```
171
+
172
+ ### 2. Sign Up / Login (CLI - GitHub Device Flow)
173
+
174
+ ```
175
+ ┌─────────────────────────────────────────────────────────────────────────────┐
176
+ │ Terminal │
177
+ ├─────────────────────────────────────────────────────────────────────────────┤
178
+ │ │
179
+ │ $ gssh auth login │
180
+ │ │
181
+ │ ! First, copy your one-time code: ABCD-1234 │
182
+ │ Press Enter to open github.com in your browser... │
183
+ │ │
184
+ │ ───────────────────────────────────────────────────────────────────────── │
185
+ │ │
186
+ │ Browser: github.com/login/device │
187
+ │ ┌─────────────────────────────────────────────────────────────────────┐ │
188
+ │ │ │ │
189
+ │ │ Device Activation │ │
190
+ │ │ │ │
191
+ │ │ Enter the code displayed on your device: │ │
192
+ │ │ │ │
193
+ │ │ ┌──────────────────────────────────────┐ │ │
194
+ │ │ │ ABCD-1234 │ │ │
195
+ │ │ └──────────────────────────────────────┘ │ │
196
+ │ │ │ │
197
+ │ │ ┌──────────┐ │ │
198
+ │ │ │ Continue │ │ │
199
+ │ │ └──────────┘ │ │
200
+ │ │ │ │
201
+ │ └─────────────────────────────────────────────────────────────────────┘ │
202
+ │ │
203
+ │ ───────────────────────────────────────────────────────────────────────── │
204
+ │ │
205
+ │ Browser: GitHub authorization page │
206
+ │ ┌─────────────────────────────────────────────────────────────────────┐ │
207
+ │ │ │ │
208
+ │ │ Authorize gitspace.sh │ │
209
+ │ │ │ │
210
+ │ │ gitspace.sh by @gitspacesh │ │
211
+ │ │ wants to access your account │ │
212
+ │ │ │ │
213
+ │ │ This will allow gitspace.sh to: │ │
214
+ │ │ • Read your profile information │ │
215
+ │ │ • Read your email addresses │ │
216
+ │ │ │ │
217
+ │ │ ┌──────────────────────┐ │ │
218
+ │ │ │ Authorize gitspace.sh │ │ │
219
+ │ │ └──────────────────────┘ │ │
220
+ │ │ │ │
221
+ │ └─────────────────────────────────────────────────────────────────────┘ │
222
+ │ │
223
+ │ ───────────────────────────────────────────────────────────────────────── │
224
+ │ │
225
+ │ Terminal (after authorization): │
226
+ │ │
227
+ │ ✓ Authentication complete │
228
+ │ ✓ Logged in as username │
229
+ │ ✓ Token saved to keychain │
230
+ │ │
231
+ └─────────────────────────────────────────────────────────────────────────────┘
232
+ ```
233
+
234
+ ### Device Flow Sequence
235
+
236
+ ```
237
+ ┌─────────────────────────────────────────────────────────────────────────────┐
238
+ │ GITHUB DEVICE FLOW - DETAILED SEQUENCE │
239
+ ├─────────────────────────────────────────────────────────────────────────────┤
240
+ │ │
241
+ │ CLI GitHub gitspace.sh API │
242
+ │ │ │ │ │
243
+ │ │ POST /login/device/code │ │ │
244
+ │ │ {client_id, scope} │ │ │
245
+ │ │────────────────────────────►│ │ │
246
+ │ │ │ │ │
247
+ │ │◄────────────────────────────│ │ │
248
+ │ │ {device_code, user_code, │ │ │
249
+ │ │ verification_uri, interval}│ │ │
250
+ │ │ │ │ │
251
+ │ │ [Display code to user] │ │ │
252
+ │ │ [Open browser] │ │ │
253
+ │ │ │ │ │
254
+ │ │ [User visits github.com/login/device] │ │
255
+ │ │ [User enters code: ABCD-1234] │ │
256
+ │ │ [User clicks "Authorize gitspace.sh"] │ │
257
+ │ │ │ │ │
258
+ │ │ POST /login/oauth/access_token (polling) │ │
259
+ │ │ {device_code, client_id, │ │ │
260
+ │ │ grant_type: device_code} │ │ │
261
+ │ │────────────────────────────►│ │ │
262
+ │ │ │ │ │
263
+ │ │◄────────────────────────────│ │ │
264
+ │ │ {access_token, token_type, │ │ │
265
+ │ │ scope} │ │ │
266
+ │ │ │ │ │
267
+ │ │ │ │
268
+ │ │ POST /auth/github/device │ │
269
+ │ │ {github_token, machine_pubkey, device_name} │ │
270
+ │ │───────────────────────────────────────────────────────────►│ │
271
+ │ │ │ │
272
+ │ │ [Verify token with GitHub API]│ │
273
+ │ │ [GET github.com/user] │ │
274
+ │ │ [Create/find account by │ │
275
+ │ │ github_id] │ │
276
+ │ │ [Create CLI token] │ │
277
+ │ │ │ │
278
+ │ │◄───────────────────────────────────────────────────────────│ │
279
+ │ │ {token: "gst_xxx", user: {github_username, ...}} │ │
280
+ │ │ │ │
281
+ │ │ [Save token to keychain] │ │ │
282
+ │ │ │ │ │
283
+ │ │
284
+ └─────────────────────────────────────────────────────────────────────────────┘
285
+ ```
286
+
287
+ ### 3. Reserve Subdomain
288
+
289
+ ```
290
+ ┌─────────────────────────────────────────────────────────────────────────────┐
291
+ │ Terminal │
292
+ ├─────────────────────────────────────────────────────────────────────────────┤
293
+ │ │
294
+ │ $ gssh host reserve brad │
295
+ │ │
296
+ │ Checking availability... ✓ │
297
+ │ Creating tunnel... ✓ │
298
+ │ Configuring DNS... ✓ │
299
+ │ Saving credentials... ✓ │
300
+ │ │
301
+ │ ✓ Reserved: brad.gitspace.sh │
302
+ │ │
303
+ │ Your subdomain is ready: │
304
+ │ • brad.gitspace.sh │
305
+ │ • *.brad.gitspace.sh (dev.brad.gitspace.sh, api.brad.gitspace.sh, etc.) │
306
+ │ │
307
+ │ Run 'gssh serve' to start hosting. │
308
+ │ │
309
+ └─────────────────────────────────────────────────────────────────────────────┘
310
+ ```
311
+
312
+ ### 4. Start Serving
313
+
314
+ ```
315
+ ┌─────────────────────────────────────────────────────────────────────────────┐
316
+ │ Terminal │
317
+ ├─────────────────────────────────────────────────────────────────────────────┤
318
+ │ │
319
+ │ $ gssh │
320
+ │ │
321
+ │ Starting Gitspace... │
322
+ │ │
323
+ │ ✓ Identity loaded │
324
+ │ ✓ Tunnel connected (brad.gitspace.sh) │
325
+ │ ✓ Relay started (accepting connections from other machines) │
326
+ │ ✓ HTTP server listening on :8080 │
327
+ │ │
328
+ │ Your machine is accessible at: │
329
+ │ • https://brad.gitspace.sh │
330
+ │ • wss://brad.gitspace.sh/ws (terminal) │
331
+ │ │
332
+ │ ┌─────────────────────────────────────────────────────────────────────┐ │
333
+ │ │ GITSPACE TUI │ │
334
+ │ │ ... │ │
335
+ │ └─────────────────────────────────────────────────────────────────────┘ │
336
+ │ │
337
+ └─────────────────────────────────────────────────────────────────────────────┘
338
+ ```
339
+
340
+ ---
341
+
342
+ ## Database Schema (D1)
343
+
344
+ ```sql
345
+ -- Users (via GitHub OAuth)
346
+ CREATE TABLE users (
347
+ id TEXT PRIMARY KEY, -- uuid
348
+ github_id TEXT UNIQUE NOT NULL,
349
+ github_username TEXT NOT NULL,
350
+ email TEXT,
351
+ name TEXT,
352
+ avatar_url TEXT,
353
+ created_at INTEGER NOT NULL,
354
+ updated_at INTEGER NOT NULL
355
+ );
356
+
357
+ -- CLI Tokens (one user can have multiple devices)
358
+ -- SECURITY: Tokens are hashed before storage. Only prefix is stored for display.
359
+ CREATE TABLE tokens (
360
+ id TEXT PRIMARY KEY, -- SHA256 hash of full token
361
+ prefix TEXT NOT NULL, -- First 8 chars for display: "gst_abc1..."
362
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
363
+ device_name TEXT,
364
+ device_fingerprint TEXT, -- Machine identity public key
365
+ created_at INTEGER NOT NULL,
366
+ expires_at INTEGER, -- Optional expiration (90 days recommended)
367
+ last_used_at INTEGER,
368
+ revoked_at INTEGER
369
+ );
370
+
371
+ CREATE INDEX idx_tokens_user ON tokens(user_id);
372
+ CREATE INDEX idx_tokens_prefix ON tokens(prefix); -- For token lookup by prefix
373
+
374
+ -- Subdomains (users can have MULTIPLE subdomains)
375
+ -- Free tier: 3 subdomains max
376
+ -- Paid tier: 10 subdomains max
377
+ CREATE TABLE subdomains (
378
+ id TEXT PRIMARY KEY, -- uuid
379
+ subdomain TEXT UNIQUE NOT NULL, -- "brad" (not full domain)
380
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
381
+ tunnel_id TEXT NOT NULL, -- Cloudflare tunnel UUID
382
+ dns_record_ids TEXT NOT NULL, -- JSON array of DNS record IDs for cleanup
383
+ tunnel_token_encrypted TEXT NOT NULL, -- Encrypted tunnel token
384
+ status TEXT NOT NULL DEFAULT 'active', -- active, suspended, deleted
385
+ is_primary BOOLEAN DEFAULT false, -- Primary subdomain for this user
386
+ created_at INTEGER NOT NULL,
387
+ updated_at INTEGER NOT NULL
388
+ );
389
+
390
+ CREATE INDEX idx_subdomains_user ON subdomains(user_id);
391
+ CREATE INDEX idx_subdomains_status ON subdomains(status);
392
+
393
+ -- Reserved subdomains (cannot be claimed by users)
394
+ CREATE TABLE reserved_subdomains (
395
+ subdomain TEXT PRIMARY KEY,
396
+ reason TEXT NOT NULL -- e.g., "system", "offensive", "trademark"
397
+ );
398
+
399
+ -- Pre-populate reserved subdomains
400
+ INSERT INTO reserved_subdomains (subdomain, reason) VALUES
401
+ ('api', 'system'), ('www', 'system'), ('admin', 'system'),
402
+ ('mail', 'system'), ('ftp', 'system'), ('relay', 'system'),
403
+ ('static', 'system'), ('cdn', 'system'), ('auth', 'system'),
404
+ ('login', 'system'), ('status', 'system'), ('docs', 'system'),
405
+ ('help', 'system'), ('support', 'system'), ('billing', 'system');
406
+
407
+ -- Subdomain access (who can connect to your relay)
408
+ CREATE TABLE subdomain_access (
409
+ id TEXT PRIMARY KEY,
410
+ subdomain_id TEXT NOT NULL REFERENCES subdomains(id) ON DELETE CASCADE,
411
+ identity_id TEXT NOT NULL, -- Public key of authorized client
412
+ label TEXT,
413
+ permissions TEXT NOT NULL, -- JSON: {read, write, manage}
414
+ created_at INTEGER NOT NULL
415
+ );
416
+
417
+ CREATE INDEX idx_subdomain_access_subdomain ON subdomain_access(subdomain_id);
418
+ CREATE INDEX idx_subdomain_access_identity ON subdomain_access(identity_id);
419
+ ```
420
+
421
+ ## Sessions (D1)
422
+
423
+ Sessions are stored in D1 (not KV) for better query support and to avoid KV write limits (1,000/day).
424
+
425
+ ```sql
426
+ -- Portal sessions
427
+ CREATE TABLE sessions (
428
+ id TEXT PRIMARY KEY, -- session ID (random UUID)
429
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
430
+ created_at INTEGER NOT NULL,
431
+ expires_at INTEGER NOT NULL, -- created_at + 7 days
432
+ ip_address TEXT, -- For audit trail
433
+ user_agent TEXT -- For audit trail
434
+ );
435
+
436
+ CREATE INDEX idx_sessions_user ON sessions(user_id);
437
+ CREATE INDEX idx_sessions_expires ON sessions(expires_at);
438
+ ```
439
+
440
+ ```typescript
441
+ // Create session
442
+ const sessionId = crypto.randomUUID();
443
+ const expiresAt = Date.now() + (7 * 24 * 60 * 60 * 1000); // 7 days
444
+
445
+ await env.DB.prepare(`
446
+ INSERT INTO sessions (id, user_id, created_at, expires_at, ip_address, user_agent)
447
+ VALUES (?, ?, ?, ?, ?, ?)
448
+ `).bind(sessionId, userId, Date.now(), expiresAt, request.headers.get('CF-Connecting-IP'), request.headers.get('User-Agent')).run();
449
+
450
+ // Validate session (with cleanup of expired)
451
+ const session = await env.DB.prepare(
452
+ 'SELECT * FROM sessions WHERE id = ? AND expires_at > ?'
453
+ ).bind(sessionId, Date.now()).first();
454
+
455
+ // Cleanup job: DELETE FROM sessions WHERE expires_at < ?
456
+ ```
457
+
458
+ Note: GitHub Device Flow handles device codes entirely through GitHub's API - we never store them.
459
+
460
+ ---
461
+
462
+ ## API Specification
463
+
464
+ ### Base URL
465
+ ```
466
+ https://api.gitspace.sh
467
+ ```
468
+
469
+ ### Authentication
470
+
471
+ **Portal (browser)**: Cookie-based sessions stored in KV
472
+
473
+ **CLI**: Bearer token in Authorization header
474
+ ```
475
+ Authorization: Bearer gst_xxxxxxxxxxxx
476
+ ```
477
+
478
+ ### Endpoints
479
+
480
+ #### Auth
481
+
482
+ ```
483
+ # Portal: GitHub OAuth (redirect-based)
484
+ GET /auth/github
485
+ → Redirects to GitHub OAuth authorize URL
486
+ → Params: client_id, redirect_uri, scope=read:user,user:email
487
+
488
+ GET /auth/github/callback?code={code}
489
+ → GitHub OAuth callback
490
+ → Exchanges code for GitHub access token
491
+ → Fetches user info from GitHub API
492
+ → Creates/updates user in D1 (keyed by github_id)
493
+ → Sets session cookie
494
+ → Redirects to dashboard
495
+
496
+ # CLI: GitHub Device Flow
497
+ POST /auth/github/device
498
+ Body: {
499
+ github_token, # GitHub access token from device flow
500
+ machine_pubkey, # Ed25519 public key (base64)
501
+ device_name, # e.g., "Brad's MacBook"
502
+ auth_timestamp, # Current timestamp (ms)
503
+ auth_signature # Signature proving private key ownership
504
+ }
505
+ → Verifies signature: sign(`gitspace-device-auth:${timestamp}`, private_key)
506
+ → Rejects if timestamp > 5 minutes old (prevent replay)
507
+ → Verifies GitHub token by calling GitHub API /user
508
+ → Creates/updates user in D1 (keyed by github_id)
509
+ → Creates CLI token in D1 (hashed)
510
+ → Returns: { token: "gst_xxx", user: { github_username, email, ... } }
511
+
512
+ # Logout
513
+ POST /auth/logout
514
+ Cookie: session
515
+ → Deletes session from KV
516
+ ```
517
+
518
+ #### User
519
+
520
+ ```
521
+ GET /me
522
+ Auth: Bearer token
523
+ → Returns: { id, github_username, email, name, avatar_url }
524
+
525
+ GET /me/tokens
526
+ Auth: Bearer token
527
+ → Returns: [{ id, device_name, created_at, last_used_at }]
528
+
529
+ DELETE /me/tokens/{tokenId}
530
+ Auth: Bearer token
531
+ → Revokes token
532
+ ```
533
+
534
+ #### Subdomains
535
+
536
+ ```
537
+ GET /subdomains
538
+ Auth: Bearer token
539
+ → Returns: [{ subdomain, status, created_at }]
540
+
541
+ GET /subdomains/check?name={subdomain}
542
+ Auth: Bearer token
543
+ → Checks: not taken, not reserved, valid format (lowercase, alphanumeric, 3-20 chars)
544
+ → Returns: { available: boolean, reason?: string }
545
+
546
+ POST /subdomains
547
+ Auth: Bearer token
548
+ Body: { subdomain, isPrimary?: boolean }
549
+ → Validates: subdomain format, not reserved, not taken
550
+ → Checks limit: free=3, paid=10 subdomains per user
551
+ → Creates tunnel via CF API
552
+ → Creates DNS records (subdomain + wildcard), stores record IDs
553
+ → Stores encrypted tunnel token
554
+ → Sets isPrimary=true if user's first subdomain
555
+ → Returns: { subdomain, hosts: ['brad.gitspace.sh', '*.brad.gitspace.sh'], isPrimary }
556
+
557
+ POST /subdomains/{subdomain}/set-primary
558
+ Auth: Bearer token
559
+ → Sets this subdomain as primary, unsets others
560
+ → Primary subdomain is used by default in `gssh serve`
561
+
562
+ GET /subdomains/{subdomain}/token
563
+ Auth: Bearer token
564
+ → Returns: { tunnelToken } (decrypted)
565
+ → Used by CLI to configure cloudflared
566
+
567
+ DELETE /subdomains/{subdomain}
568
+ Auth: Bearer token
569
+ → Deletes tunnel via CF API
570
+ → Deletes DNS records
571
+ → Marks subdomain as deleted (or releases)
572
+ ```
573
+
574
+ #### Access Control (future)
575
+
576
+ ```
577
+ GET /subdomains/{subdomain}/access
578
+ Auth: Bearer token
579
+ → Returns: [{ identity_id, label, permissions }]
580
+
581
+ POST /subdomains/{subdomain}/access
582
+ Auth: Bearer token
583
+ Body: { identityId, label, permissions }
584
+ → Grants access
585
+
586
+ DELETE /subdomains/{subdomain}/access/{identityId}
587
+ Auth: Bearer token
588
+ → Revokes access
589
+ ```
590
+
591
+ ---
592
+
593
+ ## Worker Implementation
594
+
595
+ ### Project Structure
596
+
597
+ ```
598
+ worker/
599
+ ├── src/
600
+ │ ├── index.ts # Main entry, routing
601
+ │ ├── middleware/
602
+ │ │ ├── auth.ts # Token/session validation
603
+ │ │ └── cors.ts # CORS headers
604
+ │ ├── handlers/
605
+ │ │ ├── auth.ts # OAuth, device flow
606
+ │ │ ├── user.ts # User endpoints
607
+ │ │ └── subdomains.ts # Subdomain management
608
+ │ ├── services/
609
+ │ │ ├── cloudflare.ts # CF API client (tunnels, DNS)
610
+ │ │ └── crypto.ts # Token encryption/decryption
611
+ │ └── types.ts
612
+ ├── schema.sql # D1 schema
613
+ ├── wrangler.toml
614
+ └── package.json
615
+ ```
616
+
617
+ ### wrangler.toml
618
+
619
+ ```toml
620
+ name = "gitspace-api"
621
+ main = "src/index.ts"
622
+ compatibility_date = "2024-01-01"
623
+
624
+ [vars]
625
+ GITHUB_CLIENT_ID = "xxx"
626
+ PORTAL_URL = "https://gitspace.sh"
627
+
628
+ [[d1_databases]]
629
+ binding = "DB"
630
+ database_name = "gitspace"
631
+ database_id = "xxx"
632
+
633
+ [[kv_namespaces]]
634
+ binding = "KV"
635
+ id = "xxx"
636
+
637
+ [secrets]
638
+ # Set via wrangler secret put
639
+ # GITHUB_CLIENT_SECRET
640
+ # CF_API_TOKEN
641
+ # CF_ACCOUNT_ID
642
+ # CF_ZONE_ID
643
+ # ENCRYPTION_KEY
644
+ ```
645
+
646
+ ### Key Implementation Details
647
+
648
+ ```typescript
649
+ // src/services/crypto.ts - Token hashing
650
+
651
+ export async function hashToken(token: string): Promise<string> {
652
+ const encoder = new TextEncoder();
653
+ const data = encoder.encode(token);
654
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
655
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
656
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
657
+ }
658
+
659
+ // src/middleware/auth.ts - Token validation
660
+
661
+ export async function validateToken(
662
+ request: Request,
663
+ env: Env
664
+ ): Promise<{ user: User } | null> {
665
+ const authHeader = request.headers.get('Authorization');
666
+ if (!authHeader?.startsWith('Bearer ')) return null;
667
+
668
+ const tokenPlain = authHeader.slice(7);
669
+ const tokenHash = await hashToken(tokenPlain);
670
+
671
+ // Look up by hash, check expiration and revocation
672
+ const token = await env.DB.prepare(`
673
+ SELECT t.*, u.* FROM tokens t
674
+ JOIN users u ON t.user_id = u.id
675
+ WHERE t.id = ? AND t.revoked_at IS NULL
676
+ AND (t.expires_at IS NULL OR t.expires_at > ?)
677
+ `).bind(tokenHash, Date.now()).first();
678
+
679
+ if (!token) return null;
680
+
681
+ // Update last_used_at (fire-and-forget)
682
+ env.DB.prepare('UPDATE tokens SET last_used_at = ? WHERE id = ?')
683
+ .bind(Date.now(), tokenHash).run();
684
+
685
+ return { user: token as User };
686
+ }
687
+ ```
688
+
689
+ ```typescript
690
+ // src/services/cloudflare.ts
691
+
692
+ export async function createTunnel(
693
+ env: Env,
694
+ name: string
695
+ ): Promise<{ id: string; token: string }> {
696
+ // Generate tunnel secret (32 random bytes, base64)
697
+ const secret = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))));
698
+
699
+ const response = await fetch(
700
+ `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/cfd_tunnel`,
701
+ {
702
+ method: 'POST',
703
+ headers: {
704
+ 'Authorization': `Bearer ${env.CF_API_TOKEN}`,
705
+ 'Content-Type': 'application/json',
706
+ },
707
+ body: JSON.stringify({
708
+ name: `gitspace-${name}`,
709
+ tunnel_secret: secret,
710
+ }),
711
+ }
712
+ );
713
+
714
+ const data = await response.json();
715
+ return {
716
+ id: data.result.id,
717
+ token: data.result.token,
718
+ };
719
+ }
720
+
721
+ export async function createDNSRecords(
722
+ env: Env,
723
+ subdomain: string,
724
+ tunnelId: string
725
+ ): Promise<void> {
726
+ const records = [
727
+ { name: subdomain, type: 'CNAME' }, // brad.gitspace.sh
728
+ { name: `*.${subdomain}`, type: 'CNAME' }, // *.brad.gitspace.sh
729
+ ];
730
+
731
+ for (const record of records) {
732
+ await fetch(
733
+ `https://api.cloudflare.com/client/v4/zones/${env.CF_ZONE_ID}/dns_records`,
734
+ {
735
+ method: 'POST',
736
+ headers: {
737
+ 'Authorization': `Bearer ${env.CF_API_TOKEN}`,
738
+ 'Content-Type': 'application/json',
739
+ },
740
+ body: JSON.stringify({
741
+ type: record.type,
742
+ name: record.name,
743
+ content: `${tunnelId}.cfargotunnel.com`,
744
+ proxied: true,
745
+ }),
746
+ }
747
+ );
748
+ }
749
+ }
750
+
751
+ export async function deleteTunnel(env: Env, tunnelId: string): Promise<void> {
752
+ // This immediately prevents new connections
753
+ await fetch(
754
+ `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/cfd_tunnel/${tunnelId}`,
755
+ {
756
+ method: 'DELETE',
757
+ headers: {
758
+ 'Authorization': `Bearer ${env.CF_API_TOKEN}`,
759
+ },
760
+ }
761
+ );
762
+ }
763
+
764
+ // src/handlers/auth.ts - GitHub Device Flow handler
765
+
766
+ import { ed25519 } from '@noble/curves/ed25519';
767
+
768
+ interface GitHubDeviceAuthRequest {
769
+ github_token: string;
770
+ machine_pubkey: string;
771
+ device_name: string;
772
+ auth_timestamp: number;
773
+ auth_signature: string;
774
+ }
775
+
776
+ export async function handleGitHubDeviceAuth(
777
+ request: Request,
778
+ env: Env
779
+ ): Promise<Response> {
780
+ const body: GitHubDeviceAuthRequest = await request.json();
781
+ const { github_token, machine_pubkey, device_name, auth_timestamp, auth_signature } = body;
782
+
783
+ // Step 0: Verify signature to prevent device impersonation
784
+ // SECURITY: Without this, an attacker could register with a stolen public key
785
+ const now = Date.now();
786
+ const MAX_TIMESTAMP_AGE = 5 * 60 * 1000; // 5 minutes
787
+
788
+ // Check timestamp freshness (prevent replay attacks)
789
+ if (Math.abs(now - auth_timestamp) > MAX_TIMESTAMP_AGE) {
790
+ return Response.json(
791
+ { error: 'Auth timestamp expired. Please try again.' },
792
+ { status: 401 }
793
+ );
794
+ }
795
+
796
+ // Verify the signature proves ownership of private key
797
+ const authMessage = `gitspace-device-auth:${auth_timestamp}`;
798
+ const messageBytes = new TextEncoder().encode(authMessage);
799
+ const signatureBytes = Buffer.from(auth_signature, 'base64');
800
+ const publicKeyBytes = Buffer.from(machine_pubkey, 'base64');
801
+
802
+ try {
803
+ const isValid = ed25519.verify(signatureBytes, messageBytes, publicKeyBytes);
804
+ if (!isValid) {
805
+ return Response.json(
806
+ { error: 'Invalid device signature' },
807
+ { status: 401 }
808
+ );
809
+ }
810
+ } catch (err) {
811
+ return Response.json(
812
+ { error: 'Invalid signature format' },
813
+ { status: 400 }
814
+ );
815
+ }
816
+
817
+ // Step 1: Verify GitHub token by fetching user info
818
+ const githubUserRes = await fetch('https://api.github.com/user', {
819
+ headers: {
820
+ 'Authorization': `Bearer ${github_token}`,
821
+ 'User-Agent': 'gitspace.sh',
822
+ 'Accept': 'application/vnd.github+json',
823
+ },
824
+ });
825
+
826
+ if (!githubUserRes.ok) {
827
+ return Response.json(
828
+ { error: 'Invalid GitHub token' },
829
+ { status: 401 }
830
+ );
831
+ }
832
+
833
+ const githubUser = await githubUserRes.json();
834
+
835
+ // Step 2: Fetch user emails (need scope: user:email)
836
+ const emailsRes = await fetch('https://api.github.com/user/emails', {
837
+ headers: {
838
+ 'Authorization': `Bearer ${github_token}`,
839
+ 'User-Agent': 'gitspace.sh',
840
+ 'Accept': 'application/vnd.github+json',
841
+ },
842
+ });
843
+
844
+ let email: string | null = null;
845
+ if (emailsRes.ok) {
846
+ const emails = await emailsRes.json();
847
+ const primary = emails.find((e: any) => e.primary && e.verified);
848
+ email = primary?.email || null;
849
+ }
850
+
851
+ // Step 3: Find or create user (keyed by github_id)
852
+ let user = await env.DB.prepare(
853
+ 'SELECT * FROM users WHERE github_id = ?'
854
+ ).bind(String(githubUser.id)).first();
855
+
856
+ const now = Date.now();
857
+
858
+ if (!user) {
859
+ // Create new user
860
+ const userId = crypto.randomUUID();
861
+ await env.DB.prepare(`
862
+ INSERT INTO users (id, github_id, github_username, email, name, avatar_url, created_at, updated_at)
863
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
864
+ `).bind(
865
+ userId,
866
+ String(githubUser.id),
867
+ githubUser.login,
868
+ email,
869
+ githubUser.name,
870
+ githubUser.avatar_url,
871
+ now,
872
+ now
873
+ ).run();
874
+
875
+ user = { id: userId, github_id: String(githubUser.id), github_username: githubUser.login, email };
876
+ } else {
877
+ // Update existing user
878
+ await env.DB.prepare(`
879
+ UPDATE users SET github_username = ?, email = ?, name = ?, avatar_url = ?, updated_at = ?
880
+ WHERE id = ?
881
+ `).bind(
882
+ githubUser.login,
883
+ email || user.email,
884
+ githubUser.name,
885
+ githubUser.avatar_url,
886
+ now,
887
+ user.id
888
+ ).run();
889
+ }
890
+
891
+ // Step 4: Create CLI token (hashed for storage)
892
+ const tokenPlain = `gst_${crypto.randomUUID().replace(/-/g, '')}`;
893
+ const tokenPrefix = tokenPlain.slice(0, 12); // "gst_abc12345"
894
+ const tokenHash = await hashToken(tokenPlain);
895
+ const expiresAt = now + (90 * 24 * 60 * 60 * 1000); // 90 days
896
+
897
+ await env.DB.prepare(`
898
+ INSERT INTO tokens (id, prefix, user_id, device_name, device_fingerprint, created_at, expires_at, last_used_at)
899
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
900
+ `).bind(
901
+ tokenHash, // Store hash, not plain token
902
+ tokenPrefix,
903
+ user.id,
904
+ device_name,
905
+ machine_pubkey,
906
+ now,
907
+ expiresAt,
908
+ now
909
+ ).run();
910
+
911
+ // Step 5: Return token and user info
912
+ // IMPORTANT: This is the only time the plain token is returned!
913
+ return Response.json({
914
+ token: tokenPlain,
915
+ user: {
916
+ id: user.id,
917
+ github_username: githubUser.login,
918
+ email: email,
919
+ name: githubUser.name,
920
+ avatar_url: githubUser.avatar_url,
921
+ },
922
+ });
923
+ }
924
+ ```
925
+
926
+ ---
927
+
928
+ ## CLI Implementation
929
+
930
+ ### Commands
931
+
932
+ ```bash
933
+ # Authentication
934
+ gssh auth login # Device auth flow
935
+ gssh auth logout # Clear local token
936
+ gssh auth status # Show current user
937
+
938
+ # Hosting (supports multiple subdomains: free=3, paid=10)
939
+ gssh host reserve <name> # Reserve subdomain
940
+ gssh host release [name] # Release subdomain
941
+ gssh host list # List your subdomains
942
+ gssh host set-primary <name> # Set primary subdomain for `gssh serve`
943
+ gssh host status # Show current hosting status
944
+
945
+ # Main entry (starts everything)
946
+ spaces # TUI + tunnel + relay
947
+ gssh --remote <subdomain> # Connect to remote machine
948
+ ```
949
+
950
+ ### Dependencies
951
+
952
+ ```bash
953
+ # New CLI dependencies
954
+ bun add open # Open browser URLs cross-platform
955
+ bun add which # Find executables (cloudflared check)
956
+ bun add yaml # Parse/generate cloudflared config
957
+ ```
958
+
959
+ ### Implementation
960
+
961
+ ```typescript
962
+ // src/commands/auth.ts
963
+
964
+ import open from 'open'; // Opens browser URLs cross-platform
965
+ import os from 'os';
966
+ import { getSecret, setSecret, deleteSecret } from '../utils/secrets.js';
967
+ import { loadKeypair, getPublicKeyWithoutPassword } from '../core/identity.js';
968
+ import { sign, serializePublicKey } from '../lib/tmux-lite/crypto/identity.js';
969
+ import { promptPassword } from '../utils/prompts.js';
970
+
971
+ const API_BASE = 'https://api.gitspace.sh';
972
+ const GITHUB_CLIENT_ID = 'Iv1.xxxxxxxxxxxxxxxx'; // Your GitHub OAuth App client ID
973
+
974
+ interface DeviceCodeResponse {
975
+ device_code: string;
976
+ user_code: string;
977
+ verification_uri: string;
978
+ expires_in: number;
979
+ interval: number;
980
+ }
981
+
982
+ interface GitHubTokenResponse {
983
+ access_token?: string;
984
+ token_type?: string;
985
+ scope?: string;
986
+ error?: string;
987
+ error_description?: string;
988
+ }
989
+
990
+ export async function authLogin(): Promise<void> {
991
+ // Load identity (requires password to access private key for signing)
992
+ const password = await promptPassword('Enter identity password: ');
993
+ const identity = await loadKeypair(password);
994
+
995
+ // Step 1: Request device code from GitHub
996
+ console.log('Starting GitHub authentication...');
997
+
998
+ const deviceRes = await fetch('https://github.com/login/device/code', {
999
+ method: 'POST',
1000
+ headers: {
1001
+ 'Accept': 'application/json',
1002
+ 'Content-Type': 'application/json',
1003
+ },
1004
+ body: JSON.stringify({
1005
+ client_id: GITHUB_CLIENT_ID,
1006
+ scope: 'read:user user:email',
1007
+ }),
1008
+ });
1009
+
1010
+ const deviceData: DeviceCodeResponse = await deviceRes.json();
1011
+ const { device_code, user_code, verification_uri, interval } = deviceData;
1012
+
1013
+ // Step 2: Display code and open browser
1014
+ console.log(`\n! First, copy your one-time code: ${user_code}\n`);
1015
+
1016
+ // Try to open browser, with fallback for headless/SSH environments
1017
+ const canOpenBrowser = process.stdout.isTTY && !process.env.SSH_CLIENT;
1018
+
1019
+ if (canOpenBrowser) {
1020
+ console.log(`Press Enter to open ${verification_uri} in your browser...`);
1021
+ await new Promise<void>((resolve) => {
1022
+ process.stdin.once('data', () => resolve());
1023
+ });
1024
+
1025
+ try {
1026
+ await open(verification_uri);
1027
+ console.log('\nWaiting for authorization...');
1028
+ } catch (err) {
1029
+ // Browser open failed (WSL, headless, etc.)
1030
+ console.log(`\nCould not open browser automatically.`);
1031
+ console.log(`Please open this URL manually: ${verification_uri}`);
1032
+ console.log(`\nWaiting for authorization...`);
1033
+ }
1034
+ } else {
1035
+ // Headless environment (SSH, CI, etc.)
1036
+ console.log(`Open this URL in your browser: ${verification_uri}`);
1037
+ console.log(`Enter the code: ${user_code}`);
1038
+ console.log(`\nWaiting for authorization...`);
1039
+ }
1040
+
1041
+ // Step 3: Poll GitHub for access token
1042
+ const githubToken = await pollForGitHubToken(device_code, interval);
1043
+
1044
+ // Step 4: Exchange GitHub token for gitspace.sh token
1045
+ // SECURITY: Sign auth request to prove private key ownership
1046
+ console.log('Completing authentication...');
1047
+
1048
+ const authTimestamp = Date.now();
1049
+ const authMessage = `gitspace-device-auth:${authTimestamp}`;
1050
+ const authSignature = sign(authMessage, identity.signingSecretKey);
1051
+
1052
+ const response = await fetch(`${API_BASE}/auth/github/device`, {
1053
+ method: 'POST',
1054
+ headers: { 'Content-Type': 'application/json' },
1055
+ body: JSON.stringify({
1056
+ github_token: githubToken,
1057
+ machine_pubkey: serializePublicKey(identity.signingPublicKey),
1058
+ device_name: os.hostname(),
1059
+ auth_timestamp: authTimestamp,
1060
+ auth_signature: authSignature, // Proves private key ownership
1061
+ }),
1062
+ });
1063
+
1064
+ if (!response.ok) {
1065
+ const error = await response.json();
1066
+ throw new Error(`Authentication failed: ${error.message}`);
1067
+ }
1068
+
1069
+ const { token, user } = await response.json();
1070
+
1071
+ // Step 5: Save token to keychain
1072
+ await setSecret('GITSPACE_TOKEN', token);
1073
+
1074
+ console.log(`\n✓ Authentication complete`);
1075
+ console.log(`✓ Logged in as ${user.github_username}`);
1076
+ console.log(`✓ Token saved to keychain`);
1077
+ }
1078
+
1079
+ async function pollForGitHubToken(deviceCode: string, interval: number): Promise<string> {
1080
+ const maxAttempts = 60; // ~5 minutes with default 5s interval
1081
+
1082
+ for (let i = 0; i < maxAttempts; i++) {
1083
+ await sleep(interval * 1000);
1084
+
1085
+ const res = await fetch('https://github.com/login/oauth/access_token', {
1086
+ method: 'POST',
1087
+ headers: {
1088
+ 'Accept': 'application/json',
1089
+ 'Content-Type': 'application/json',
1090
+ },
1091
+ body: JSON.stringify({
1092
+ client_id: GITHUB_CLIENT_ID,
1093
+ device_code: deviceCode,
1094
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
1095
+ }),
1096
+ });
1097
+
1098
+ const data: GitHubTokenResponse = await res.json();
1099
+
1100
+ if (data.access_token) {
1101
+ return data.access_token;
1102
+ }
1103
+
1104
+ if (data.error === 'authorization_pending') {
1105
+ // User hasn't authorized yet, keep polling
1106
+ continue;
1107
+ }
1108
+
1109
+ if (data.error === 'slow_down') {
1110
+ // Rate limited, increase interval
1111
+ interval += 5;
1112
+ continue;
1113
+ }
1114
+
1115
+ if (data.error === 'expired_token') {
1116
+ throw new Error('Authorization expired. Please try again.');
1117
+ }
1118
+
1119
+ if (data.error === 'access_denied') {
1120
+ throw new Error('Authorization denied by user.');
1121
+ }
1122
+
1123
+ throw new Error(`GitHub auth error: ${data.error_description || data.error}`);
1124
+ }
1125
+
1126
+ throw new Error('Authorization timeout. Please try again.');
1127
+ }
1128
+
1129
+ function sleep(ms: number): Promise<void> {
1130
+ return new Promise(resolve => setTimeout(resolve, ms));
1131
+ }
1132
+
1133
+ export async function authLogout(): Promise<void> {
1134
+ await deleteSecret('GITSPACE_TOKEN');
1135
+ console.log('✓ Logged out');
1136
+ }
1137
+
1138
+ export async function authStatus(): Promise<void> {
1139
+ const token = await getSecret('GITSPACE_TOKEN');
1140
+
1141
+ if (!token) {
1142
+ console.log('Not logged in. Run: gssh auth login');
1143
+ return;
1144
+ }
1145
+
1146
+ const res = await fetch(`${API_BASE}/me`, {
1147
+ headers: { 'Authorization': `Bearer ${token}` },
1148
+ });
1149
+
1150
+ if (!res.ok) {
1151
+ console.log('Session expired. Run: gssh auth login');
1152
+ return;
1153
+ }
1154
+
1155
+ const user = await res.json();
1156
+ console.log(`Logged in as: ${user.github_username}`);
1157
+ console.log(`Email: ${user.email || '(not set)'}`);
1158
+ }
1159
+
1160
+ // src/commands/host.ts
1161
+
1162
+ export async function hostReserve(subdomain: string): Promise<void> {
1163
+ const token = await getSecret('GITSPACE_TOKEN');
1164
+ if (!token) {
1165
+ console.log('Not logged in. Run: gssh auth login');
1166
+ return;
1167
+ }
1168
+
1169
+ // Check availability
1170
+ console.log('Checking availability...');
1171
+ const checkRes = await fetch(
1172
+ `${API_BASE}/subdomains/check?name=${subdomain}`,
1173
+ { headers: { 'Authorization': `Bearer ${token}` } }
1174
+ );
1175
+ const { available } = await checkRes.json();
1176
+
1177
+ if (!available) {
1178
+ console.error(`Subdomain "${subdomain}" is not available`);
1179
+ return;
1180
+ }
1181
+
1182
+ // Reserve
1183
+ console.log('Creating tunnel...');
1184
+ const res = await fetch(`${API_BASE}/subdomains`, {
1185
+ method: 'POST',
1186
+ headers: {
1187
+ 'Authorization': `Bearer ${token}`,
1188
+ 'Content-Type': 'application/json',
1189
+ },
1190
+ body: JSON.stringify({ subdomain }),
1191
+ });
1192
+
1193
+ if (!res.ok) {
1194
+ const { error } = await res.json();
1195
+ console.error(`Failed: ${error}`);
1196
+ return;
1197
+ }
1198
+
1199
+ const data = await res.json();
1200
+ console.log(`✓ Reserved: ${data.subdomain}.gitspace.sh`);
1201
+ console.log(` Wildcard: *.${data.subdomain}.gitspace.sh`);
1202
+ if (data.isPrimary) {
1203
+ console.log(` (set as primary)`);
1204
+ }
1205
+
1206
+ // Fetch and store tunnel token for this subdomain
1207
+ const tokenRes = await fetch(
1208
+ `${API_BASE}/subdomains/${subdomain}/token`,
1209
+ { headers: { 'Authorization': `Bearer ${token}` } }
1210
+ );
1211
+ const { tunnelToken } = await tokenRes.json();
1212
+
1213
+ // Store tunnel token in keychain (per-subdomain)
1214
+ // SECURITY: Uses system keychain, not plaintext file
1215
+ await setSecret(`TUNNEL_TOKEN_${subdomain}`, tunnelToken);
1216
+
1217
+ console.log('\nRun `gssh` to start hosting.');
1218
+ console.log(`Or `gssh host list` to see all your subdomains.`);
1219
+ }
1220
+
1221
+ export async function hostList(): Promise<void> {
1222
+ const token = await getSecret('GITSPACE_TOKEN');
1223
+ if (!token) {
1224
+ console.log('Not logged in. Run: gssh auth login');
1225
+ return;
1226
+ }
1227
+
1228
+ const res = await fetch(`${API_BASE}/subdomains`, {
1229
+ headers: { 'Authorization': `Bearer ${token}` },
1230
+ });
1231
+
1232
+ const subdomains = await res.json();
1233
+
1234
+ if (subdomains.length === 0) {
1235
+ console.log('No subdomains reserved. Run: gssh host reserve <name>');
1236
+ return;
1237
+ }
1238
+
1239
+ console.log('Your subdomains:\n');
1240
+ for (const sub of subdomains) {
1241
+ const primary = sub.is_primary ? ' (primary)' : '';
1242
+ const status = sub.status === 'active' ? '✓' : '✗';
1243
+ console.log(` ${status} ${sub.subdomain}.gitspace.sh${primary}`);
1244
+ console.log(` Created: ${new Date(sub.created_at).toLocaleDateString()}`);
1245
+ }
1246
+
1247
+ console.log(`\n${subdomains.length}/3 subdomains used (free tier)`);
1248
+ }
1249
+
1250
+ export async function hostSetPrimary(subdomain: string): Promise<void> {
1251
+ const token = await getSecret('GITSPACE_TOKEN');
1252
+ if (!token) {
1253
+ console.log('Not logged in. Run: gssh auth login');
1254
+ return;
1255
+ }
1256
+
1257
+ const res = await fetch(`${API_BASE}/subdomains/${subdomain}/set-primary`, {
1258
+ method: 'POST',
1259
+ headers: { 'Authorization': `Bearer ${token}` },
1260
+ });
1261
+
1262
+ if (!res.ok) {
1263
+ const { error } = await res.json();
1264
+ console.error(`Failed: ${error}`);
1265
+ return;
1266
+ }
1267
+
1268
+ console.log(`✓ ${subdomain}.gitspace.sh is now your primary subdomain`);
1269
+ }
1270
+ ```
1271
+
1272
+ ### gssh serve Integration
1273
+
1274
+ ```typescript
1275
+ // src/commands/serve.ts - cloudflared integration
1276
+
1277
+ import { spawn, type ChildProcess } from 'child_process';
1278
+ import { join } from 'path';
1279
+ import { writeFile } from 'fs/promises';
1280
+ import * as yaml from 'yaml';
1281
+
1282
+ let cloudflaredProcess: ChildProcess | null = null;
1283
+
1284
+ async function startCloudflared(subdomain: string): Promise<void> {
1285
+ // SECURITY: Read tunnel token from keychain (not from config file)
1286
+ const tunnelToken = await getSecret(`TUNNEL_TOKEN_${subdomain}`);
1287
+ if (!tunnelToken) {
1288
+ throw new Error(`No tunnel token found for ${subdomain}. Run: gssh host reserve ${subdomain}`);
1289
+ }
1290
+
1291
+ // Write cloudflared config for spaces (separate from user's own config)
1292
+ const configDir = join(os.homedir(), '.spaces');
1293
+ const configPath = join(configDir, 'cloudflared.yml');
1294
+
1295
+ await writeFile(configPath, yaml.stringify({
1296
+ // Token-based auth (no credentials file needed)
1297
+ ingress: [
1298
+ // Main subdomain
1299
+ {
1300
+ hostname: `${subdomain}.gitspace.sh`,
1301
+ service: 'http://localhost:8080'
1302
+ },
1303
+ // Wildcard for workspaces/services
1304
+ {
1305
+ hostname: `*.${subdomain}.gitspace.sh`,
1306
+ service: 'http://localhost:8080'
1307
+ },
1308
+ // Catch-all (required)
1309
+ { service: 'http_status:404' }
1310
+ ],
1311
+ }));
1312
+
1313
+ // Check cloudflared is installed
1314
+ const cloudflaredPath = await which('cloudflared').catch(() => null);
1315
+ if (!cloudflaredPath) {
1316
+ throw new Error(
1317
+ 'cloudflared not found. Install it:\n' +
1318
+ ' macOS: brew install cloudflared\n' +
1319
+ ' Linux: See https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'
1320
+ );
1321
+ }
1322
+
1323
+ // Start cloudflared with token via env var (not CLI arg - visible in `ps`)
1324
+ // SECURITY: TUNNEL_TOKEN env var is not visible to other users on the system
1325
+ cloudflaredProcess = spawn('cloudflared', [
1326
+ 'tunnel',
1327
+ '--config', configPath,
1328
+ 'run',
1329
+ ], {
1330
+ stdio: ['ignore', 'pipe', 'pipe'],
1331
+ env: {
1332
+ ...process.env,
1333
+ TUNNEL_TOKEN: tunnelToken, // Pass token via env, not CLI arg
1334
+ },
1335
+ });
1336
+
1337
+ cloudflaredProcess.stdout?.on('data', (data) => {
1338
+ logger.dim(`[cloudflared] ${data.toString().trim()}`);
1339
+ });
1340
+
1341
+ cloudflaredProcess.stderr?.on('data', (data) => {
1342
+ const msg = data.toString().trim();
1343
+ if (msg.includes('error')) {
1344
+ logger.error(`[cloudflared] ${msg}`);
1345
+ } else {
1346
+ logger.dim(`[cloudflared] ${msg}`);
1347
+ }
1348
+ });
1349
+
1350
+ // Handle cloudflared crash - restart with backoff
1351
+ cloudflaredProcess.on('exit', (code) => {
1352
+ if (code !== 0 && !shuttingDown) {
1353
+ logger.warn(`[cloudflared] Exited with code ${code}, restarting in 5s...`);
1354
+ setTimeout(() => startCloudflared(subdomain), 5000);
1355
+ }
1356
+ });
1357
+
1358
+ // Wait for tunnel to be ready
1359
+ await waitForTunnel(subdomain);
1360
+ }
1361
+
1362
+ async function waitForTunnel(subdomain: string, timeout = 30000): Promise<void> {
1363
+ const start = Date.now();
1364
+
1365
+ while (Date.now() - start < timeout) {
1366
+ try {
1367
+ const res = await fetch(`https://${subdomain}.gitspace.sh/health`, {
1368
+ signal: AbortSignal.timeout(2000),
1369
+ });
1370
+ if (res.ok) return;
1371
+ } catch {
1372
+ // Not ready yet
1373
+ }
1374
+ await sleep(1000);
1375
+ }
1376
+
1377
+ throw new Error('Tunnel failed to connect');
1378
+ }
1379
+
1380
+ function stopCloudflared(): void {
1381
+ if (cloudflaredProcess) {
1382
+ cloudflaredProcess.kill();
1383
+ cloudflaredProcess = null;
1384
+ }
1385
+ }
1386
+ ```
1387
+
1388
+ ---
1389
+
1390
+ ## Local Server (Embedded Relay + HTTP)
1391
+
1392
+ ```typescript
1393
+ // src/serve/local-server.ts
1394
+
1395
+ import { serve } from 'bun';
1396
+ import net from 'net';
1397
+
1398
+ interface LocalServerConfig {
1399
+ port: number;
1400
+ subdomain: string;
1401
+ identity: Identity;
1402
+ accessList: AccessControlList;
1403
+ sessionManager: ClientSessionManager;
1404
+ serviceRouter: ServiceRouter;
1405
+ }
1406
+
1407
+ // Check if a port is available
1408
+ async function isPortAvailable(port: number): Promise<boolean> {
1409
+ return new Promise((resolve) => {
1410
+ const server = net.createServer();
1411
+ server.once('error', () => resolve(false));
1412
+ server.once('listening', () => {
1413
+ server.close();
1414
+ resolve(true);
1415
+ });
1416
+ server.listen(port);
1417
+ });
1418
+ }
1419
+
1420
+ // Find an available port, starting from preferred
1421
+ async function findAvailablePort(preferred: number, maxAttempts = 10): Promise<number> {
1422
+ for (let i = 0; i < maxAttempts; i++) {
1423
+ const port = preferred + i;
1424
+ if (await isPortAvailable(port)) {
1425
+ return port;
1426
+ }
1427
+ }
1428
+ throw new Error(`No available port found in range ${preferred}-${preferred + maxAttempts - 1}`);
1429
+ }
1430
+
1431
+ export async function createLocalServer(config: LocalServerConfig) {
1432
+ const { subdomain, identity, accessList, sessionManager, serviceRouter } = config;
1433
+
1434
+ // Find available port (fallback if 8080 is taken)
1435
+ const port = await findAvailablePort(config.port);
1436
+ if (port !== config.port) {
1437
+ logger.warn(`Port ${config.port} in use, using ${port} instead`);
1438
+ }
1439
+
1440
+ return serve({
1441
+ port,
1442
+
1443
+ async fetch(req, server) {
1444
+ const url = new URL(req.url);
1445
+ const host = req.headers.get('host') || '';
1446
+
1447
+ // Health check
1448
+ if (url.pathname === '/health') {
1449
+ return Response.json({ status: 'ok', subdomain });
1450
+ }
1451
+
1452
+ // WebSocket upgrade for terminal
1453
+ if (url.pathname === '/ws') {
1454
+ const upgraded = server.upgrade(req, {
1455
+ data: { type: 'terminal' }
1456
+ });
1457
+ if (upgraded) return undefined;
1458
+ return new Response('WebSocket upgrade failed', { status: 500 });
1459
+ }
1460
+
1461
+ // Route HTTP to services based on subdomain
1462
+ // e.g., dev.brad.gitspace.sh → dev workspace
1463
+ // e.g., api.brad.gitspace.sh → api service
1464
+ const subHost = extractSubdomain(host, subdomain);
1465
+ return serviceRouter.route(subHost, req);
1466
+ },
1467
+
1468
+ websocket: {
1469
+ open(ws) {
1470
+ sessionManager.handleConnect(ws.data.connectionId);
1471
+ },
1472
+
1473
+ message(ws, message) {
1474
+ // Reuse existing terminal protocol handling
1475
+ sessionManager.handleMessage(ws.data.connectionId, message);
1476
+ },
1477
+
1478
+ close(ws, code, reason) {
1479
+ sessionManager.handleDisconnect(ws.data.connectionId, reason);
1480
+ },
1481
+ },
1482
+ });
1483
+ }
1484
+
1485
+ function extractSubdomain(host: string, baseSubdomain: string): string | null {
1486
+ // host: "dev.brad.gitspace.sh"
1487
+ // baseSubdomain: "brad"
1488
+ // returns: "dev"
1489
+
1490
+ const pattern = new RegExp(`^(.+)\\.${baseSubdomain}\\.gitspace\\.sh$`);
1491
+ const match = host.match(pattern);
1492
+ return match ? match[1] : null;
1493
+ }
1494
+ ```
1495
+
1496
+ ---
1497
+
1498
+ ## Revocation Flow
1499
+
1500
+ ```typescript
1501
+ // worker/src/handlers/subdomains.ts
1502
+
1503
+ export async function revokeSubdomain(
1504
+ subdomain: string,
1505
+ userId: string,
1506
+ env: Env
1507
+ ): Promise<void> {
1508
+ // 1. Get subdomain record
1509
+ const record = await env.DB.prepare(
1510
+ 'SELECT * FROM subdomains WHERE subdomain = ? AND user_id = ?'
1511
+ ).bind(subdomain, userId).first();
1512
+
1513
+ if (!record) {
1514
+ throw new Error('Subdomain not found');
1515
+ }
1516
+
1517
+ // 2. Delete tunnel (IMMEDIATE - blocks new connections)
1518
+ await deleteTunnel(env, record.tunnel_id);
1519
+
1520
+ // 3. Delete DNS records
1521
+ await deleteDNSRecords(env, subdomain);
1522
+
1523
+ // 4. Update database
1524
+ await env.DB.prepare(
1525
+ 'UPDATE subdomains SET status = ?, updated_at = ? WHERE id = ?'
1526
+ ).bind('deleted', Date.now(), record.id).run();
1527
+
1528
+ // 5. Optionally: Release subdomain for reuse after cooldown
1529
+ // await scheduleSubdomainRelease(subdomain, 30 * 24 * 60 * 60 * 1000); // 30 days
1530
+ }
1531
+
1532
+ // Admin revocation (abuse cases)
1533
+ export async function adminRevokeUser(userId: string, env: Env): Promise<void> {
1534
+ // Get all user's subdomains
1535
+ const subdomains = await env.DB.prepare(
1536
+ 'SELECT * FROM subdomains WHERE user_id = ? AND status = ?'
1537
+ ).bind(userId, 'active').all();
1538
+
1539
+ // Revoke each subdomain
1540
+ for (const sub of subdomains.results) {
1541
+ await deleteTunnel(env, sub.tunnel_id);
1542
+ await deleteDNSRecords(env, sub.subdomain);
1543
+ }
1544
+
1545
+ // Mark all as suspended
1546
+ await env.DB.prepare(
1547
+ 'UPDATE subdomains SET status = ?, updated_at = ? WHERE user_id = ?'
1548
+ ).bind('suspended', Date.now(), userId).run();
1549
+
1550
+ // Revoke all tokens
1551
+ await env.DB.prepare(
1552
+ 'UPDATE tokens SET revoked_at = ? WHERE user_id = ?'
1553
+ ).bind(Date.now(), userId).run();
1554
+ }
1555
+ ```
1556
+
1557
+ ---
1558
+
1559
+ ## Peer Relay Model
1560
+
1561
+ Secondary machines connect to primary machine's embedded relay:
1562
+
1563
+ ```typescript
1564
+ // src/commands/serve.ts
1565
+
1566
+ import { getSecret } from '../utils/secrets.js';
1567
+
1568
+ /**
1569
+ * Host config stored in ~/gitspace/host.json (non-sensitive data only)
1570
+ * Sensitive tunnel tokens are stored in keychain via Bun.secrets
1571
+ */
1572
+ interface HostConfig {
1573
+ subdomain: string; // Primary subdomain
1574
+ subdomains?: string[]; // Additional subdomains (if any)
1575
+ createdAt: number;
1576
+ }
1577
+
1578
+ export async function serve(options: ServeOptions): Promise<void> {
1579
+ const hostConfig = await getHostConfig(); // Reads non-sensitive config from ~/gitspace/
1580
+
1581
+ if (hostConfig?.subdomain) {
1582
+ // PRIMARY MODE: Has subdomain, runs cloudflared + relay
1583
+ // Tunnel token is read from keychain inside startPrimaryMode
1584
+ await startPrimaryMode(hostConfig.subdomain);
1585
+ } else if (options.relay) {
1586
+ // SECONDARY MODE: Connects to another machine's relay
1587
+ await startSecondaryMode(options.relay);
1588
+ } else {
1589
+ // LOCAL ONLY MODE: No remote access
1590
+ await startLocalMode();
1591
+ }
1592
+ }
1593
+
1594
+ async function startPrimaryMode(subdomain: string): Promise<void> {
1595
+ // 1. Start cloudflared (reads tunnel token from keychain)
1596
+ await startCloudflared(subdomain);
1597
+
1598
+ // 2. Start local HTTP/WS server
1599
+ const server = createLocalServer({
1600
+ port: 8080,
1601
+ subdomain,
1602
+ // ...
1603
+ });
1604
+
1605
+ // 3. Start embedded relay (accepts connections from secondary machines)
1606
+ const relay = createEmbeddedRelay({
1607
+ // Reuses existing relay protocol
1608
+ });
1609
+
1610
+ logger.success(`Primary mode: https://${subdomain}.gitspace.sh`);
1611
+ }
1612
+
1613
+ async function startSecondaryMode(relayUrl: string): Promise<void> {
1614
+ // Connect to primary machine's relay (same as current relay connection)
1615
+ const ws = new WebSocket(`wss://${relayUrl}/ws`);
1616
+
1617
+ // Register this machine
1618
+ ws.onopen = () => {
1619
+ ws.send(JSON.stringify({
1620
+ type: 'register_machine',
1621
+ machineId: identity.id,
1622
+ signingKey: identity.signingPublicKey,
1623
+ keyExchangeKey: identity.keyExchangePublicKey,
1624
+ }));
1625
+ };
1626
+
1627
+ // Handle client connections (same as current)
1628
+ // ...
1629
+
1630
+ logger.success(`Secondary mode: Connected to ${relayUrl}`);
1631
+ }
1632
+ ```
1633
+
1634
+ ---
1635
+
1636
+ ## Security Considerations
1637
+
1638
+ ### Token Security
1639
+
1640
+ **Storage Patterns:**
1641
+
1642
+ | Data Type | Location | Rationale |
1643
+ |-----------|----------|-----------|
1644
+ | gitspace.sh API token (`gst_xxx`) | System keychain via `Bun.secrets` | Sensitive, needs secure storage |
1645
+ | Tunnel tokens | System keychain via `Bun.secrets` | Sensitive, grants tunnel access |
1646
+ | Machine identity private keys | `~/gitspace/.identity/keypair.json` (encrypted) | Already password-protected |
1647
+ | Relay config (URL, machine ID) | `~/gitspace/.identity/relay.json` | Non-sensitive metadata |
1648
+ | API tokens in D1 | SHA-256 hash only | Never store plaintext |
1649
+
1650
+ **Device Registration Security:**
1651
+ - Signature required: `sign("gitspace-device-auth:${timestamp}", privateKey)`
1652
+ - Timestamp must be within 5 minutes (prevents replay attacks)
1653
+ - Without signature, attacker could register with stolen public key
1654
+
1655
+ **Bun.secrets Integration (Global):**
1656
+ ```typescript
1657
+ // src/utils/secrets.ts - Cross-platform secure secret storage
1658
+
1659
+ const SERVICE_NAME = 'com.gitspace-cli';
1660
+
1661
+ /**
1662
+ * Store a global secret (not project-scoped)
1663
+ * Uses system keychain: macOS Keychain, Linux libsecret, Windows Credential Manager
1664
+ */
1665
+ export async function setSecret(key: string, value: string): Promise<void> {
1666
+ await Bun.secrets.set({
1667
+ service: SERVICE_NAME,
1668
+ name: key,
1669
+ value,
1670
+ });
1671
+ }
1672
+
1673
+ /**
1674
+ * Retrieve a global secret
1675
+ */
1676
+ export async function getSecret(key: string): Promise<string | null> {
1677
+ return Bun.secrets.get({
1678
+ service: SERVICE_NAME,
1679
+ name: key,
1680
+ });
1681
+ }
1682
+
1683
+ /**
1684
+ * Delete a global secret
1685
+ */
1686
+ export async function deleteSecret(key: string): Promise<boolean> {
1687
+ return Bun.secrets.delete({
1688
+ service: SERVICE_NAME,
1689
+ name: key,
1690
+ });
1691
+ }
1692
+
1693
+ // Project-scoped secrets (existing API)
1694
+ function buildProjectSecretName(projectName: string, key: string): string {
1695
+ return `${projectName}:${key}`;
1696
+ }
1697
+
1698
+ export async function setProjectSecret(
1699
+ projectName: string,
1700
+ key: string,
1701
+ value: string
1702
+ ): Promise<void> {
1703
+ await Bun.secrets.set({
1704
+ service: SERVICE_NAME,
1705
+ name: buildProjectSecretName(projectName, key),
1706
+ value,
1707
+ });
1708
+ }
1709
+
1710
+ export async function getProjectSecret(
1711
+ projectName: string,
1712
+ key: string
1713
+ ): Promise<string | null> {
1714
+ return Bun.secrets.get({
1715
+ service: SERVICE_NAME,
1716
+ name: buildProjectSecretName(projectName, key),
1717
+ });
1718
+ }
1719
+ ```
1720
+
1721
+ **What goes where:**
1722
+ ```
1723
+ ~/gitspace/
1724
+ ├── .identity/
1725
+ │ ├── keypair.json # Password-encrypted Ed25519/X25519 keys
1726
+ │ ├── access-list.json # Authorized public keys (not sensitive)
1727
+ │ ├── machine.json # Machine ID, label (not sensitive)
1728
+ │ └── relay.json # Relay URL, machine ID (not sensitive)
1729
+ │ # NOTE: No secrets in relay.json anymore!
1730
+ └── cloudflared.yml # Tunnel routing config (not sensitive)
1731
+
1732
+ System Keychain (via Bun.secrets):
1733
+ ├── GITSPACE_TOKEN # gitspace.sh API token (sensitive!)
1734
+ └── TUNNEL_TOKEN_{subdomain} # Per-subdomain tunnel token (sensitive!)
1735
+ ```
1736
+
1737
+ ### Revocation Speed
1738
+
1739
+ | Action | Effect | Speed |
1740
+ |--------|--------|-------|
1741
+ | Delete tunnel | New connections blocked | Immediate |
1742
+ | Rotate token | Old token invalid | Immediate |
1743
+ | Revoke API token | API access blocked | Immediate |
1744
+
1745
+ ### Abuse Prevention
1746
+
1747
+ - Rate limiting on subdomain creation
1748
+ - Subdomain naming rules (no offensive terms)
1749
+ - Reserved subdomains (api, www, admin, etc.)
1750
+ - Cooldown period before subdomain reuse
1751
+
1752
+ ---
1753
+
1754
+ ## Cost Analysis
1755
+
1756
+ ### Cloudflare Costs (You Pay)
1757
+
1758
+ | Item | Cost | Notes |
1759
+ |------|------|-------|
1760
+ | Domain (gitspace.sh) | ~$10/year | One-time |
1761
+ | Total TLS | $10/month | For *.*.gitspace.sh wildcards |
1762
+ | Workers | Free tier | 100k requests/day |
1763
+ | D1 | Free tier | 5GB storage |
1764
+ | KV | Free tier | 100k reads/day |
1765
+
1766
+ **Total: ~$130/year**
1767
+
1768
+ ### User Costs
1769
+
1770
+ | Item | Cost |
1771
+ |------|------|
1772
+ | Everything | $0 |
1773
+
1774
+ Users run tunnels on their own machines, use their own bandwidth.
1775
+
1776
+ ---
1777
+
1778
+ ## Launch Checklist
1779
+
1780
+ ```
1781
+ □ Cloudflare Setup
1782
+ □ Add gitspace.sh domain
1783
+ □ Enable Total TLS ($10/mo)
1784
+ □ Create API token with permissions:
1785
+ □ Account > Cloudflare Tunnel > Edit
1786
+ □ Zone > DNS > Edit
1787
+ □ Zone > SSL and Certificates > Edit
1788
+ □ Note Account ID, Zone ID
1789
+
1790
+ □ GitHub OAuth App
1791
+ □ Create OAuth App at github.com/settings/applications/new
1792
+ □ Set homepage URL: https://gitspace.sh
1793
+ □ Set callback URL: https://api.gitspace.sh/auth/github/callback
1794
+ □ Enable Device Flow (checkbox in OAuth App settings)
1795
+ □ Note Client ID, Client Secret
1796
+
1797
+ □ Worker Deployment
1798
+ □ Create D1 database, run schema.sql
1799
+ □ Create KV namespace
1800
+ □ Set secrets (wrangler secret put)
1801
+ □ Deploy worker to api.gitspace.sh
1802
+
1803
+ □ Portal Deployment
1804
+ □ Deploy to gitspace.sh via Pages
1805
+
1806
+ □ CLI Updates
1807
+ □ gssh auth login/logout/status
1808
+ □ gssh host reserve/release/list
1809
+ □ cloudflared integration in gssh serve
1810
+ □ Test full flow
1811
+
1812
+ □ Documentation
1813
+ □ Getting started guide
1814
+ □ FAQ
1815
+ ```
1816
+
1817
+ ---
1818
+
1819
+ *Last updated: 2025-01*