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.
- package/.claude/settings.local.json +21 -0
- package/.gitspace/bundle.json +50 -0
- package/.gitspace/select/01-status.sh +40 -0
- package/.gitspace/setup/01-install-deps.sh +12 -0
- package/.gitspace/setup/02-typecheck.sh +16 -0
- package/AGENTS.md +439 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +25 -0
- package/README.md +607 -0
- package/bin/gssh +62 -0
- package/bun.lock +647 -0
- package/docs/CONNECTION.md +623 -0
- package/docs/GATEWAY-WORKER.md +319 -0
- package/docs/GETTING-STARTED.md +448 -0
- package/docs/GITSPACE-PLATFORM.md +1819 -0
- package/docs/INFRASTRUCTURE.md +1347 -0
- package/docs/PROTOCOL.md +619 -0
- package/docs/QUICKSTART.md +174 -0
- package/docs/RELAY.md +327 -0
- package/docs/REMOTE-DESIGN.md +549 -0
- package/docs/ROADMAP.md +564 -0
- package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
- package/docs/STACK-DESIGN.md +588 -0
- package/docs/UNIFIED_ARCHITECTURE.md +292 -0
- package/experiments/pty-benchmark.ts +148 -0
- package/experiments/pty-latency.ts +100 -0
- package/experiments/router/client.ts +199 -0
- package/experiments/router/protocol.ts +74 -0
- package/experiments/router/router.ts +217 -0
- package/experiments/router/session.ts +180 -0
- package/experiments/router/test.ts +133 -0
- package/experiments/socket-bandwidth.ts +77 -0
- package/homebrew/gitspace.rb +45 -0
- package/landing-page/ATTRIBUTIONS.md +3 -0
- package/landing-page/README.md +11 -0
- package/landing-page/bun.lock +801 -0
- package/landing-page/guidelines/Guidelines.md +61 -0
- package/landing-page/index.html +37 -0
- package/landing-page/package.json +90 -0
- package/landing-page/postcss.config.mjs +15 -0
- package/landing-page/public/_redirects +1 -0
- package/landing-page/public/favicon.png +0 -0
- package/landing-page/src/app/App.tsx +53 -0
- package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
- package/landing-page/src/app/components/ui/accordion.tsx +66 -0
- package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
- package/landing-page/src/app/components/ui/alert.tsx +66 -0
- package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
- package/landing-page/src/app/components/ui/avatar.tsx +53 -0
- package/landing-page/src/app/components/ui/badge.tsx +46 -0
- package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
- package/landing-page/src/app/components/ui/button.tsx +57 -0
- package/landing-page/src/app/components/ui/calendar.tsx +75 -0
- package/landing-page/src/app/components/ui/card.tsx +92 -0
- package/landing-page/src/app/components/ui/carousel.tsx +241 -0
- package/landing-page/src/app/components/ui/chart.tsx +353 -0
- package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
- package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
- package/landing-page/src/app/components/ui/command.tsx +177 -0
- package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
- package/landing-page/src/app/components/ui/dialog.tsx +135 -0
- package/landing-page/src/app/components/ui/drawer.tsx +132 -0
- package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
- package/landing-page/src/app/components/ui/form.tsx +168 -0
- package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
- package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
- package/landing-page/src/app/components/ui/input.tsx +21 -0
- package/landing-page/src/app/components/ui/label.tsx +24 -0
- package/landing-page/src/app/components/ui/menubar.tsx +276 -0
- package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
- package/landing-page/src/app/components/ui/pagination.tsx +127 -0
- package/landing-page/src/app/components/ui/popover.tsx +48 -0
- package/landing-page/src/app/components/ui/progress.tsx +31 -0
- package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
- package/landing-page/src/app/components/ui/resizable.tsx +56 -0
- package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
- package/landing-page/src/app/components/ui/select.tsx +189 -0
- package/landing-page/src/app/components/ui/separator.tsx +28 -0
- package/landing-page/src/app/components/ui/sheet.tsx +139 -0
- package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
- package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
- package/landing-page/src/app/components/ui/slider.tsx +63 -0
- package/landing-page/src/app/components/ui/sonner.tsx +25 -0
- package/landing-page/src/app/components/ui/switch.tsx +31 -0
- package/landing-page/src/app/components/ui/table.tsx +116 -0
- package/landing-page/src/app/components/ui/tabs.tsx +66 -0
- package/landing-page/src/app/components/ui/textarea.tsx +18 -0
- package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
- package/landing-page/src/app/components/ui/toggle.tsx +47 -0
- package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
- package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
- package/landing-page/src/app/components/ui/utils.ts +6 -0
- package/landing-page/src/components/docs/DocsContent.tsx +718 -0
- package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
- package/landing-page/src/components/landing/CTA.tsx +59 -0
- package/landing-page/src/components/landing/Comparison.tsx +84 -0
- package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
- package/landing-page/src/components/landing/Features.tsx +201 -0
- package/landing-page/src/components/landing/Hero.tsx +142 -0
- package/landing-page/src/components/landing/Pricing.tsx +140 -0
- package/landing-page/src/components/landing/Roadmap.tsx +86 -0
- package/landing-page/src/components/landing/Security.tsx +81 -0
- package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
- package/landing-page/src/components/landing/UseCases.tsx +55 -0
- package/landing-page/src/components/landing/Workflow.tsx +101 -0
- package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
- package/landing-page/src/components/layout/Footer.tsx +55 -0
- package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
- package/landing-page/src/components/ui/badge.tsx +39 -0
- package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
- package/landing-page/src/components/ui/button.tsx +57 -0
- package/landing-page/src/components/ui/card.tsx +79 -0
- package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
- package/landing-page/src/components/ui/separator.tsx +28 -0
- package/landing-page/src/lib/utils.ts +6 -0
- package/landing-page/src/main.tsx +10 -0
- package/landing-page/src/pages/Dashboard.tsx +133 -0
- package/landing-page/src/pages/DocsPage.tsx +79 -0
- package/landing-page/src/pages/LandingPage.tsx +31 -0
- package/landing-page/src/pages/TerminalView.tsx +106 -0
- package/landing-page/src/styles/fonts.css +0 -0
- package/landing-page/src/styles/index.css +3 -0
- package/landing-page/src/styles/tailwind.css +4 -0
- package/landing-page/src/styles/theme.css +181 -0
- package/landing-page/vite.config.ts +19 -0
- package/npm/darwin-arm64/bin/gssh +0 -0
- package/npm/darwin-arm64/package.json +20 -0
- package/package.json +74 -0
- package/scripts/build.ts +284 -0
- package/scripts/release.ts +140 -0
- package/src/__tests__/test-utils.ts +298 -0
- package/src/commands/__tests__/serve-messages.test.ts +190 -0
- package/src/commands/access.ts +298 -0
- package/src/commands/add.ts +452 -0
- package/src/commands/auth.ts +364 -0
- package/src/commands/connect.ts +287 -0
- package/src/commands/directory.ts +16 -0
- package/src/commands/host.ts +396 -0
- package/src/commands/identity.ts +184 -0
- package/src/commands/list.ts +200 -0
- package/src/commands/relay.ts +315 -0
- package/src/commands/remove.ts +241 -0
- package/src/commands/serve.ts +1493 -0
- package/src/commands/share.ts +456 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/switch.ts +353 -0
- package/src/commands/tmux.ts +317 -0
- package/src/core/__tests__/access.test.ts +240 -0
- package/src/core/access.ts +277 -0
- package/src/core/bundle.ts +342 -0
- package/src/core/config.ts +510 -0
- package/src/core/git.ts +317 -0
- package/src/core/github.ts +151 -0
- package/src/core/identity.ts +631 -0
- package/src/core/linear.ts +225 -0
- package/src/core/shell.ts +161 -0
- package/src/core/trusted-relays.ts +315 -0
- package/src/index.ts +821 -0
- package/src/lib/remote-session/index.ts +7 -0
- package/src/lib/remote-session/protocol.ts +267 -0
- package/src/lib/remote-session/session-handler.ts +581 -0
- package/src/lib/remote-session/workspace-scanner.ts +167 -0
- package/src/lib/tmux-lite/README.md +81 -0
- package/src/lib/tmux-lite/cli.ts +796 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
- package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
- package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
- package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
- package/src/lib/tmux-lite/crypto/frames.ts +141 -0
- package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
- package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
- package/src/lib/tmux-lite/crypto/identity.ts +286 -0
- package/src/lib/tmux-lite/crypto/index.ts +51 -0
- package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
- package/src/lib/tmux-lite/crypto/invites.ts +215 -0
- package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
- package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
- package/src/lib/tmux-lite/crypto/keys.ts +47 -0
- package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
- package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
- package/src/lib/tmux-lite/handshake-handler.ts +451 -0
- package/src/lib/tmux-lite/protocol.test.ts +307 -0
- package/src/lib/tmux-lite/protocol.ts +266 -0
- package/src/lib/tmux-lite/relay-client.ts +506 -0
- package/src/lib/tmux-lite/server.ts +1250 -0
- package/src/lib/tmux-lite/shell-integration.sh +37 -0
- package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
- package/src/lib/tmux-lite/terminal-queries.ts +49 -0
- package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
- package/src/relay/__tests__/helpers/auth.ts +354 -0
- package/src/relay/__tests__/helpers/ports.ts +51 -0
- package/src/relay/__tests__/protocol-validation.test.ts +265 -0
- package/src/relay/authorization.ts +303 -0
- package/src/relay/embedded-assets.generated.d.ts +15 -0
- package/src/relay/identity.ts +352 -0
- package/src/relay/index.ts +57 -0
- package/src/relay/pipes.test.ts +427 -0
- package/src/relay/pipes.ts +195 -0
- package/src/relay/protocol.ts +804 -0
- package/src/relay/registries.test.ts +437 -0
- package/src/relay/registries.ts +593 -0
- package/src/relay/server.test.ts +1323 -0
- package/src/relay/server.ts +1092 -0
- package/src/relay/signing.ts +238 -0
- package/src/relay/types.ts +69 -0
- package/src/serve/client-session-manager.ts +622 -0
- package/src/serve/daemon.ts +497 -0
- package/src/serve/pty-session.ts +236 -0
- package/src/serve/types.ts +169 -0
- package/src/shared/components/Flow.tsx +453 -0
- package/src/shared/components/Flow.tui.tsx +343 -0
- package/src/shared/components/Flow.web.tsx +442 -0
- package/src/shared/components/Inbox.tsx +446 -0
- package/src/shared/components/Inbox.tui.tsx +262 -0
- package/src/shared/components/Inbox.web.tsx +329 -0
- package/src/shared/components/MachineList.tsx +187 -0
- package/src/shared/components/MachineList.tui.tsx +161 -0
- package/src/shared/components/MachineList.web.tsx +210 -0
- package/src/shared/components/ProjectList.tsx +176 -0
- package/src/shared/components/ProjectList.tui.tsx +109 -0
- package/src/shared/components/ProjectList.web.tsx +143 -0
- package/src/shared/components/SpacesBrowser.tsx +332 -0
- package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
- package/src/shared/components/SpacesBrowser.web.tsx +221 -0
- package/src/shared/components/index.ts +103 -0
- package/src/shared/hooks/index.ts +16 -0
- package/src/shared/hooks/useNavigation.ts +226 -0
- package/src/shared/index.ts +122 -0
- package/src/shared/providers/LocalMachineProvider.ts +425 -0
- package/src/shared/providers/MachineProvider.ts +165 -0
- package/src/shared/providers/RemoteMachineProvider.ts +444 -0
- package/src/shared/providers/index.ts +26 -0
- package/src/shared/types.ts +145 -0
- package/src/tui/adapters.ts +120 -0
- package/src/tui/app.tsx +1816 -0
- package/src/tui/components/Terminal.tsx +580 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useAppState.ts +314 -0
- package/src/tui/hooks/useDaemonStatus.ts +174 -0
- package/src/tui/hooks/useInboxTUI.ts +113 -0
- package/src/tui/hooks/useRemoteMachines.ts +209 -0
- package/src/tui/index.ts +24 -0
- package/src/tui/state.ts +299 -0
- package/src/tui/terminal-bracketed-paste.test.ts +45 -0
- package/src/tui/terminal-bracketed-paste.ts +47 -0
- package/src/types/bundle.ts +112 -0
- package/src/types/config.ts +89 -0
- package/src/types/errors.ts +206 -0
- package/src/types/identity.ts +284 -0
- package/src/types/workspace-fuzzy.ts +49 -0
- package/src/types/workspace.ts +151 -0
- package/src/utils/bun-socket-writer.ts +80 -0
- package/src/utils/deps.ts +127 -0
- package/src/utils/fuzzy-match.ts +125 -0
- package/src/utils/logger.ts +127 -0
- package/src/utils/markdown.ts +254 -0
- package/src/utils/onboarding.ts +229 -0
- package/src/utils/prompts.ts +114 -0
- package/src/utils/run-commands.ts +112 -0
- package/src/utils/run-scripts.ts +142 -0
- package/src/utils/sanitize.ts +98 -0
- package/src/utils/secrets.ts +122 -0
- package/src/utils/shell-escape.ts +40 -0
- package/src/utils/utf8.ts +79 -0
- package/src/utils/workspace-state.ts +47 -0
- package/src/web/README.md +73 -0
- package/src/web/bun.lock +575 -0
- package/src/web/eslint.config.js +23 -0
- package/src/web/index.html +16 -0
- package/src/web/package.json +37 -0
- package/src/web/public/vite.svg +1 -0
- package/src/web/src/App.tsx +604 -0
- package/src/web/src/assets/react.svg +1 -0
- package/src/web/src/components/Terminal.tsx +207 -0
- package/src/web/src/hooks/useRelayConnection.ts +224 -0
- package/src/web/src/hooks/useTerminal.ts +699 -0
- package/src/web/src/index.css +55 -0
- package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
- package/src/web/src/lib/crypto/frames.ts +205 -0
- package/src/web/src/lib/crypto/handshake.ts +396 -0
- package/src/web/src/lib/crypto/identity.ts +128 -0
- package/src/web/src/lib/crypto/keyexchange.ts +246 -0
- package/src/web/src/lib/crypto/relay-signing.ts +53 -0
- package/src/web/src/lib/invite.ts +58 -0
- package/src/web/src/lib/storage/identity-store.ts +94 -0
- package/src/web/src/main.tsx +10 -0
- package/src/web/src/types/identity.ts +45 -0
- package/src/web/tsconfig.app.json +28 -0
- package/src/web/tsconfig.json +7 -0
- package/src/web/tsconfig.node.json +26 -0
- package/src/web/vite.config.ts +31 -0
- package/todo-security.md +92 -0
- package/tsconfig.json +23 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
- package/worker/bun.lock +237 -0
- package/worker/package.json +22 -0
- package/worker/schema.sql +96 -0
- package/worker/src/handlers/auth.ts +451 -0
- package/worker/src/handlers/subdomains.ts +376 -0
- package/worker/src/handlers/user.ts +98 -0
- package/worker/src/index.ts +70 -0
- package/worker/src/middleware/auth.ts +152 -0
- package/worker/src/services/cloudflare.ts +609 -0
- package/worker/src/types.ts +96 -0
- package/worker/tsconfig.json +15 -0
- 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*
|