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,451 @@
1
+ /**
2
+ * Authentication handlers
3
+ *
4
+ * Handles GitHub OAuth flow (portal) and Device Flow (CLI)
5
+ */
6
+
7
+ import { Hono } from 'hono';
8
+ import { ed25519 } from '@noble/curves/ed25519';
9
+ import type { Env, User, GitHubUser } from '../types';
10
+ import { hashToken } from '../middleware/auth';
11
+
12
+ const app = new Hono<{ Bindings: Env }>();
13
+
14
+ // ============================================================================
15
+ // GitHub OAuth (Portal - redirect-based)
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Start GitHub OAuth flow
20
+ * GET /auth/github
21
+ */
22
+ app.get('/github', (c) => {
23
+ const params = new URLSearchParams({
24
+ client_id: c.env.GITHUB_CLIENT_ID,
25
+ redirect_uri: `https://api.gitspace.sh/auth/github/callback`,
26
+ scope: 'read:user user:email',
27
+ state: crypto.randomUUID(),
28
+ });
29
+
30
+ return c.redirect(`https://github.com/login/oauth/authorize?${params}`);
31
+ });
32
+
33
+ /**
34
+ * GitHub OAuth callback
35
+ * GET /auth/github/callback?code=xxx
36
+ */
37
+ app.get('/github/callback', async (c) => {
38
+ const code = c.req.query('code');
39
+
40
+ if (!code) {
41
+ return c.redirect(`${c.env.PORTAL_URL}?error=missing_code`);
42
+ }
43
+
44
+ try {
45
+ // Exchange code for access token
46
+ const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
47
+ method: 'POST',
48
+ headers: {
49
+ Accept: 'application/json',
50
+ 'Content-Type': 'application/json',
51
+ },
52
+ body: JSON.stringify({
53
+ client_id: c.env.GITHUB_CLIENT_ID,
54
+ client_secret: c.env.GITHUB_CLIENT_SECRET,
55
+ code,
56
+ }),
57
+ });
58
+
59
+ const tokenData = (await tokenRes.json()) as {
60
+ access_token?: string;
61
+ error?: string;
62
+ };
63
+
64
+ if (!tokenData.access_token) {
65
+ return c.redirect(`${c.env.PORTAL_URL}?error=token_exchange_failed`);
66
+ }
67
+
68
+ // Fetch GitHub user
69
+ const githubUser = await fetchGitHubUser(tokenData.access_token);
70
+
71
+ // Find or create user (with account limit check)
72
+ const maxAccounts = parseInt(c.env.MAX_ACCOUNTS, 10) || undefined;
73
+ let user: User;
74
+ try {
75
+ user = await findOrCreateUser(c.env.DB, githubUser, maxAccounts);
76
+ } catch (error) {
77
+ if (error instanceof Error && error.message === 'ACCOUNT_LIMIT_REACHED') {
78
+ return c.redirect(`${c.env.PORTAL_URL}?error=waitlist`);
79
+ }
80
+ throw error;
81
+ }
82
+
83
+ // Create session
84
+ const sessionId = crypto.randomUUID();
85
+ const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days
86
+
87
+ await c.env.DB.prepare(
88
+ `
89
+ INSERT INTO sessions (id, user_id, created_at, expires_at, ip_address, user_agent)
90
+ VALUES (?, ?, ?, ?, ?, ?)
91
+ `
92
+ )
93
+ .bind(
94
+ sessionId,
95
+ user.id,
96
+ Date.now(),
97
+ expiresAt,
98
+ c.req.header('CF-Connecting-IP'),
99
+ c.req.header('User-Agent')
100
+ )
101
+ .run();
102
+
103
+ // Redirect with session cookie
104
+ return new Response(null, {
105
+ status: 302,
106
+ headers: {
107
+ Location: `${c.env.PORTAL_URL}/dashboard`,
108
+ 'Set-Cookie': `session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${7 * 24 * 60 * 60}`,
109
+ },
110
+ });
111
+ } catch (error) {
112
+ console.error('OAuth callback error:', error);
113
+ return c.redirect(`${c.env.PORTAL_URL}?error=auth_failed`);
114
+ }
115
+ });
116
+
117
+ // ============================================================================
118
+ // GitHub Device Flow (CLI)
119
+ // ============================================================================
120
+
121
+ interface DeviceAuthRequest {
122
+ github_token: string;
123
+ machine_pubkey: string;
124
+ device_name: string;
125
+ auth_timestamp: number;
126
+ auth_signature: string;
127
+ }
128
+
129
+ /**
130
+ * Exchange GitHub token for gitspace.sh CLI token
131
+ * POST /auth/github/device
132
+ *
133
+ * SECURITY: Requires Ed25519 signature to prevent device impersonation
134
+ */
135
+ app.post('/github/device', async (c) => {
136
+ const body = await c.req.json<DeviceAuthRequest>();
137
+ const {
138
+ github_token,
139
+ machine_pubkey,
140
+ device_name,
141
+ auth_timestamp,
142
+ auth_signature,
143
+ } = body;
144
+
145
+ // Validate required fields
146
+ if (!github_token || !machine_pubkey || !device_name) {
147
+ return c.json({ error: 'Missing required fields' }, 400);
148
+ }
149
+
150
+ if (!auth_timestamp || !auth_signature) {
151
+ return c.json({ error: 'Missing auth signature fields' }, 400);
152
+ }
153
+
154
+ if (typeof device_name !== 'string') {
155
+ return c.json({ error: 'Invalid device_name' }, 400);
156
+ }
157
+
158
+ const normalizedDeviceName = device_name.trim();
159
+ if (!/^[a-zA-Z0-9 _.-]{1,64}$/.test(normalizedDeviceName)) {
160
+ return c.json({ error: 'Invalid device_name' }, 400);
161
+ }
162
+
163
+ // ========================================================================
164
+ // Step 0: Verify signature to prevent device impersonation
165
+ // ========================================================================
166
+
167
+ const now = Date.now();
168
+ const MAX_TIMESTAMP_AGE = 5 * 60 * 1000; // 5 minutes
169
+
170
+ // Check timestamp freshness (prevent replay attacks)
171
+ if (Math.abs(now - auth_timestamp) > MAX_TIMESTAMP_AGE) {
172
+ return c.json(
173
+ { error: 'Auth timestamp expired. Please try again.' },
174
+ 401
175
+ );
176
+ }
177
+
178
+ // Verify the signature proves ownership of private key
179
+ const authMessage = `gitspace-device-auth:${auth_timestamp}`;
180
+ const messageBytes = new TextEncoder().encode(authMessage);
181
+
182
+ let signatureBytes: Uint8Array;
183
+ let publicKeyBytes: Uint8Array;
184
+
185
+ try {
186
+ signatureBytes = new Uint8Array(
187
+ atob(auth_signature)
188
+ .split('')
189
+ .map((c) => c.charCodeAt(0))
190
+ );
191
+ publicKeyBytes = new Uint8Array(
192
+ atob(machine_pubkey)
193
+ .split('')
194
+ .map((c) => c.charCodeAt(0))
195
+ );
196
+ } catch {
197
+ return c.json({ error: 'Invalid signature or public key format' }, 400);
198
+ }
199
+
200
+ try {
201
+ const isValid = ed25519.verify(signatureBytes, messageBytes, publicKeyBytes);
202
+ if (!isValid) {
203
+ return c.json({ error: 'Invalid device signature' }, 401);
204
+ }
205
+ } catch (err) {
206
+ return c.json({ error: 'Signature verification failed' }, 401);
207
+ }
208
+
209
+ // ========================================================================
210
+ // Step 1: Verify GitHub token by fetching user info
211
+ // ========================================================================
212
+
213
+ let githubUser: GitHubUser;
214
+ try {
215
+ githubUser = await fetchGitHubUser(github_token);
216
+ } catch (error) {
217
+ return c.json({ error: 'Invalid GitHub token' }, 401);
218
+ }
219
+
220
+ // ========================================================================
221
+ // Step 2: Find or create user (with account limit check)
222
+ // ========================================================================
223
+
224
+ const maxAccounts = parseInt(c.env.MAX_ACCOUNTS, 10) || undefined;
225
+ let user: User;
226
+ try {
227
+ user = await findOrCreateUser(c.env.DB, githubUser, maxAccounts);
228
+ } catch (error) {
229
+ if (error instanceof Error && error.message === 'ACCOUNT_LIMIT_REACHED') {
230
+ return c.json(
231
+ {
232
+ error: 'Account limit reached',
233
+ message:
234
+ 'gitspace.sh is currently in private beta. Sign up for the waitlist at https://gitspace.sh',
235
+ },
236
+ 503
237
+ );
238
+ }
239
+ throw error;
240
+ }
241
+
242
+ // ========================================================================
243
+ // Step 3: Create CLI token (hashed for storage)
244
+ // ========================================================================
245
+
246
+ const tokenPlain = `gst_${crypto.randomUUID().replace(/-/g, '')}`;
247
+ const tokenPrefix = tokenPlain.slice(0, 12); // "gst_abc12345"
248
+ const tokenHash = await hashToken(tokenPlain);
249
+ const expiresAt = now + 90 * 24 * 60 * 60 * 1000; // 90 days
250
+
251
+ await c.env.DB.prepare(
252
+ `
253
+ INSERT INTO tokens (id, prefix, user_id, device_name, device_fingerprint, created_at, expires_at, last_used_at)
254
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
255
+ `
256
+ )
257
+ .bind(
258
+ tokenHash, // Store hash, not plain token
259
+ tokenPrefix,
260
+ user.id,
261
+ normalizedDeviceName,
262
+ machine_pubkey,
263
+ now,
264
+ expiresAt,
265
+ now
266
+ )
267
+ .run();
268
+
269
+ // ========================================================================
270
+ // Step 4: Return token and user info
271
+ // ========================================================================
272
+
273
+ // IMPORTANT: This is the only time the plain token is returned!
274
+ return c.json({
275
+ token: tokenPlain,
276
+ user: {
277
+ id: user.id,
278
+ github_username: githubUser.login,
279
+ email: githubUser.email,
280
+ name: githubUser.name,
281
+ avatar_url: githubUser.avatar_url,
282
+ },
283
+ });
284
+ });
285
+
286
+ /**
287
+ * Logout (clear session)
288
+ * POST /auth/logout
289
+ */
290
+ app.post('/logout', async (c) => {
291
+ const sessionCookie = c.req.header('Cookie')?.match(/session=([^;]+)/)?.[1];
292
+
293
+ if (sessionCookie) {
294
+ await c.env.DB.prepare('DELETE FROM sessions WHERE id = ?')
295
+ .bind(sessionCookie)
296
+ .run();
297
+ }
298
+
299
+ return new Response(null, {
300
+ status: 200,
301
+ headers: {
302
+ 'Set-Cookie': 'session=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0',
303
+ },
304
+ });
305
+ });
306
+
307
+ // ============================================================================
308
+ // Helper Functions
309
+ // ============================================================================
310
+
311
+ /**
312
+ * Fetch GitHub user info from API
313
+ */
314
+ async function fetchGitHubUser(accessToken: string): Promise<GitHubUser> {
315
+ const userRes = await fetch('https://api.github.com/user', {
316
+ headers: {
317
+ Authorization: `Bearer ${accessToken}`,
318
+ 'User-Agent': 'gitspace.sh',
319
+ Accept: 'application/vnd.github+json',
320
+ },
321
+ });
322
+
323
+ if (!userRes.ok) {
324
+ throw new Error('Failed to fetch GitHub user');
325
+ }
326
+
327
+ const user = (await userRes.json()) as GitHubUser;
328
+
329
+ // If email not in profile, try to get it from emails endpoint
330
+ if (!user.email) {
331
+ const emailsRes = await fetch('https://api.github.com/user/emails', {
332
+ headers: {
333
+ Authorization: `Bearer ${accessToken}`,
334
+ 'User-Agent': 'gitspace.sh',
335
+ Accept: 'application/vnd.github+json',
336
+ },
337
+ });
338
+
339
+ if (emailsRes.ok) {
340
+ const emails = (await emailsRes.json()) as Array<{
341
+ email: string;
342
+ primary: boolean;
343
+ verified: boolean;
344
+ }>;
345
+ const primary = emails.find((e) => e.primary && e.verified);
346
+ if (primary) {
347
+ user.email = primary.email;
348
+ }
349
+ }
350
+ }
351
+
352
+ return user;
353
+ }
354
+
355
+ /**
356
+ * Find or create a user by GitHub ID
357
+ * @throws Error if account limit reached (for new users)
358
+ */
359
+ async function findOrCreateUser(
360
+ db: D1Database,
361
+ githubUser: GitHubUser,
362
+ maxAccounts?: number
363
+ ): Promise<User> {
364
+ const now = Date.now();
365
+ const githubId = String(githubUser.id);
366
+
367
+ // Try to find existing user
368
+ let user = await db
369
+ .prepare('SELECT * FROM users WHERE github_id = ?')
370
+ .bind(githubId)
371
+ .first<User>();
372
+
373
+ if (user) {
374
+ // Update user info
375
+ await db
376
+ .prepare(
377
+ `
378
+ UPDATE users SET
379
+ github_username = ?,
380
+ email = COALESCE(?, email),
381
+ name = ?,
382
+ avatar_url = ?,
383
+ updated_at = ?
384
+ WHERE id = ?
385
+ `
386
+ )
387
+ .bind(
388
+ githubUser.login,
389
+ githubUser.email,
390
+ githubUser.name,
391
+ githubUser.avatar_url,
392
+ now,
393
+ user.id
394
+ )
395
+ .run();
396
+
397
+ // Refresh user data
398
+ user = await db
399
+ .prepare('SELECT * FROM users WHERE id = ?')
400
+ .bind(user.id)
401
+ .first<User>();
402
+ } else {
403
+ // Check account limit before creating new user
404
+ if (maxAccounts !== undefined) {
405
+ const countResult = await db
406
+ .prepare('SELECT COUNT(*) as count FROM users')
407
+ .first<{ count: number }>();
408
+
409
+ if (countResult && countResult.count >= maxAccounts) {
410
+ throw new Error('ACCOUNT_LIMIT_REACHED');
411
+ }
412
+ }
413
+
414
+ // Create new user
415
+ const userId = crypto.randomUUID();
416
+
417
+ await db
418
+ .prepare(
419
+ `
420
+ INSERT INTO users (id, github_id, github_username, email, name, avatar_url, created_at, updated_at)
421
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
422
+ `
423
+ )
424
+ .bind(
425
+ userId,
426
+ githubId,
427
+ githubUser.login,
428
+ githubUser.email,
429
+ githubUser.name,
430
+ githubUser.avatar_url,
431
+ now,
432
+ now
433
+ )
434
+ .run();
435
+
436
+ user = {
437
+ id: userId,
438
+ github_id: githubId,
439
+ github_username: githubUser.login,
440
+ email: githubUser.email,
441
+ name: githubUser.name,
442
+ avatar_url: githubUser.avatar_url,
443
+ created_at: now,
444
+ updated_at: now,
445
+ };
446
+ }
447
+
448
+ return user!;
449
+ }
450
+
451
+ export default app;