History

feat: add headless `t3 connect` setup for SSH hosts (#3749)

Theo Browne committed 3 months ago

Commit 7a820ab1 parent 53e3c9835 files · +3017 −144Browse files

Unified · Split

35 files
  1. .env.example +4 −0
  2. .plans/t3-connect-remote-setup.html +257 −0
  3. apps/server/src/bin.test.ts +3 −3
  4. apps/server/src/cli/connect.test.ts +42 −0
  5. apps/server/src/cli/connect.ts +245 −56
  6. apps/server/src/cloud/CliTokenManager.test.ts +264 −0
  7. apps/server/src/cloud/CliTokenManager.ts +283 −68
  8. apps/server/src/cloud/bootService.test.ts +529 −0
  9. apps/server/src/cloud/bootService.ts +452 −0
  10. apps/server/src/cloud/cliAuthHtml.test.ts +13 −0
  11. apps/server/src/cloud/cliAuthHtml.ts +140 −0
  12. apps/server/src/cloud/http.test.ts +1 −0
  13. apps/server/src/cloud/publicConfig.test.ts +35 −0
  14. apps/server/src/cloud/publicConfig.ts +40 −1
  15. apps/server/src/persistence/Migrations.ts +4 −8
  16. apps/server/src/server.ts +4 −1
  17. apps/web/src/cloud/connectCliAuth.test.ts +69 −0
  18. apps/web/src/cloud/connectCliAuth.ts +91 −0
  19. apps/web/src/cloud/publicConfig.ts +1 −1
  20. apps/web/src/components/SidebarStageBackdrop.tsx +5 −1
  21. apps/web/src/components/auth/AuthSurfaceShell.tsx +44 −0
  22. apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +188 −0
  23. apps/web/src/hostedPairing.ts +2 −2
  24. apps/web/src/routeTree.gen.ts +42 −0
  25. apps/web/src/routes/__root.tsx +1 −1
  26. apps/web/src/routes/connect.tsx +13 −0
  27. apps/web/src/routes/connect_.callback.tsx +13 −0
  28. apps/web/src/vite-env.d.ts +1 −0
  29. apps/web/vite.config.ts +4 −0
  30. packages/shared/package.json +4 −0
  31. packages/shared/src/connectAuth.test.ts +78 −0
  32. packages/shared/src/connectAuth.ts +124 −0
  33. packages/shared/src/hostProcess.ts +14 −0
  34. packages/shared/src/remote.ts +1 −1
  35. scripts/lib/public-config.ts +6 −1
.env.example+4−0
@@ -18,6 +18,10 @@ · 17 unmodified lines
1818 # Get this from your relay deployment. `infra/relay` deploys update it automatically.
1919 # T3CODE_RELAY_URL=https://relay.example.com
2020
21+# Optional: hosted app origin used by the CLI's out-of-band OAuth flow.
22+# Defaults to https://app.t3.codes; override to test against a staging deployment.
23+# T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes
24+
2125 # Public, ingest-only mobile OpenTelemetry configuration.
2226 # T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces
2327 # T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev
.plans/t3-connect-remote-setup.html+257−0
@@ -0,0 +1,257 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<title>Plan: seamless `npx t3 connect` for remote boxes</title>
7+<style>
8+ :root {
9+ --bg: #000;
10+ --fg: #fff;
11+ --muted: #a3a3a3;
12+ --surface: #121212;
13+ --surface2: #1a1a1a;
14+ --border: #2a2a2a;
15+ --accent: #7c9cff;
16+ --green: #4ade80;
17+ --amber: #fbbf24;
18+ --red: #f87171;
19+ }
20+ * { box-sizing: border-box; }
21+ body {
22+ margin: 0;
23+ background: var(--bg);
24+ color: var(--fg);
25+ font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
26+ }
27+ main { max-width: 880px; margin: 0 auto; padding: 48px 24px 96px; }
28+ h1 { font-size: 30px; line-height: 1.25; margin: 0 0 8px; }
29+ h2 { font-size: 21px; margin: 48px 0 12px; padding-top: 24px; border-top: 1px solid var(--border); }
30+ h3 { font-size: 16px; margin: 24px 0 8px; }
31+ p, li { color: #e5e5e5; }
32+ .sub { color: var(--muted); margin: 0 0 24px; }
33+ code, pre {
34+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
35+ font-size: 13.5px;
36+ }
37+ code { background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
38+ pre {
39+ background: var(--surface);
40+ border: 1px solid var(--border);
41+ border-radius: 8px;
42+ padding: 16px;
43+ overflow-x: auto;
44+ line-height: 1.5;
45+ }
46+ pre code { background: none; border: none; padding: 0; }
47+ .card {
48+ background: var(--surface);
49+ border: 1px solid var(--border);
50+ border-radius: 10px;
51+ padding: 18px 20px;
52+ margin: 16px 0;
53+ }
54+ .card h3 { margin-top: 0; }
55+ table { border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 14.5px; }
56+ th, td { text-align: left; padding: 8px 12px; border: 1px solid var(--border); vertical-align: top; }
57+ th { background: var(--surface2); font-weight: 600; }
58+ td code { white-space: nowrap; }
59+ .pill {
60+ display: inline-block; font-size: 12px; font-weight: 600; border-radius: 999px;
61+ padding: 2px 10px; margin-right: 6px; vertical-align: 2px;
62+ }
63+ .pill.core { background: #1e2a4a; color: var(--accent); }
64+ .pill.new { background: #14301e; color: var(--green); }
65+ .pill.later { background: #33270e; color: var(--amber); }
66+ .ok { color: var(--green); }
67+ .warn { color: var(--amber); }
68+ .no { color: var(--red); }
69+ ul { padding-left: 22px; }
70+ li { margin: 4px 0; }
71+ .terminal {
72+ background: #0a0a0a; border: 1px solid var(--border); border-radius: 8px;
73+ padding: 16px; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
74+ font-size: 13.5px; line-height: 1.65; color: #d4d4d4; overflow-x: auto;
75+ }
76+ .terminal .dim { color: #737373; }
77+ .terminal .g { color: var(--green); }
78+ .terminal .b { color: var(--accent); }
79+ figure { margin: 24px 0; }
80+ figcaption { color: var(--muted); font-size: 13px; margin-top: 8px; text-align: center; }
81+ svg text { font-family: -apple-system, "Segoe UI", sans-serif; }
82+ a { color: var(--accent); }
83+ .kv { color: var(--muted); font-size: 13.5px; }
84+</style>
85+</head>
86+<body>
87+<main>
88+
89+<h1>Seamless <code>npx t3 connect</code> for remote boxes</h1>
90+<p class="sub">Design principle: <strong>the smallest diff that ships the UX.</strong> No relay/infra changes, no new backend surface, no new auth primitives &mdash; every step reuses code that already exists. One PR, built as four phases with clear commit boundaries &mdash; each phase compiles, passes tests, and leaves the product working, so the PR reviews commit-by-commit. (Phase 4, web-triggered update, is an optional follow-up PR.)</p>
91+
92+<div class="terminal">
93+$ npx t3 connect<br><br>
94+To set up T3 Connect, open this URL and sign in:<br>
95+&nbsp;&nbsp;<span class="b">https://app.t3.codes/connect#B64URL_STATE_AND_CHALLENGE</span><br><br>
96+Enter your authentication code: <span class="dim">[code]</span><br><br>
97+<span class="g">Connected as theo@t3.gg!</span><br><br>
98+Run T3 Code in the background whenever this machine boots? (y/n): y<br><br>
99+<span class="g">T3 Code is set up and ready to go.</span>
100+</div>
101+
102+<h2>Why this is a small change</h2>
103+<p>The entire <code>t3 connect</code> data plane already works: Clerk PKCE token exchange, encrypted secret store, cloudflared relay-client install, relay environment linking, DPoP tokens. The <em>only</em> broken piece on an SSH box is the redirect: <code>CliTokenManager.login()</code> hardcodes a loopback callback (<code>http://127.0.0.1:34338/callback</code>) that requires a browser on the same machine.</p>
104+<p>We swap that one leg for a hosted out-of-band authorization page and keep everything else. Because PKCE's <code>code_verifier</code> never leaves the box, the displayed one-time code is useless to anyone who sees it &mdash; no new token-minting or storage is needed anywhere.</p>
105+
106+<div class="card">
107+<h3>Reused as-is (zero changes)</h3>
108+<ul>
109+ <li><code>exchangeToken()</code> PKCE exchange &mdash; <code>apps/server/src/cloud/CliTokenManager.ts:147</code></li>
110+ <li>Token persistence in <code>ServerSecretStore</code> (<code>cloud-cli-oauth-token</code>)</li>
111+ <li><code>acquireRelayClientForLink()</code> cloudflared install + progress &mdash; <code>cli/connect.ts:146</code></li>
112+ <li><code>CliState.setCliDesiredCloudLink()</code> + server-side provisioning on start</li>
113+ <li>All relay endpoints (<code>infra/relay</code>) and contracts &mdash; <strong>untouched</strong></li>
114+ <li>Existing subcommands <code>login/link/status/unlink/logout</code> &mdash; semantics unchanged</li>
115+ <li>Web app Clerk session + hosted-page precedent (<code>routes/pair.tsx</code>, <code>hostedPairing.ts</code>)</li>
116+</ul>
117+</div>
118+
119+<h2>Auth flow (hosted out-of-band OAuth, Clerk PKCE)</h2>
120+
121+<figure>
122+<svg viewBox="0 0 820 470" width="100%" role="img" aria-label="Sequence diagram of the out-of-band OAuth flow">
123+ <!-- lanes -->
124+ <rect x="20" y="16" width="220" height="44" rx="8" fill="#121212" stroke="#2a2a2a"/>
125+ <rect x="300" y="16" width="220" height="44" rx="8" fill="#121212" stroke="#2a2a2a"/>
126+ <rect x="580" y="16" width="220" height="44" rx="8" fill="#121212" stroke="#2a2a2a"/>
127+ <text x="130" y="43" fill="#fff" font-size="14" font-weight="600" text-anchor="middle">Remote box &#8212; t3 CLI</text>
128+ <text x="410" y="43" fill="#fff" font-size="14" font-weight="600" text-anchor="middle">Laptop &#8212; app.t3.codes</text>
129+ <text x="690" y="43" fill="#fff" font-size="14" font-weight="600" text-anchor="middle">Clerk</text>
130+ <line x1="130" y1="60" x2="130" y2="450" stroke="#2a2a2a"/>
131+ <line x1="410" y1="60" x2="410" y2="450" stroke="#2a2a2a"/>
132+ <line x1="690" y1="60" x2="690" y2="450" stroke="#2a2a2a"/>
133+
134+ <!-- 1: generate + print -->
135+ <rect x="30" y="80" width="200" height="34" rx="6" fill="#1e2a4a"/>
136+ <text x="130" y="101" fill="#7c9cff" font-size="12.5" text-anchor="middle">1. gen verifier + challenge + state</text>
137+
138+ <!-- 2: user opens URL -->
139+ <line x1="130" y1="140" x2="404" y2="140" stroke="#7c9cff" stroke-dasharray="5 4"/>
140+ <polygon points="404,140 394,135 394,145" fill="#7c9cff"/>
141+ <text x="267" y="132" fill="#a3a3a3" font-size="12.5" text-anchor="middle">2. user opens /connect#&#123;state,challenge&#125;</text>
142+
143+ <!-- 3: redirect to clerk -->
144+ <line x1="410" y1="185" x2="684" y2="185" stroke="#e5e5e5"/>
145+ <polygon points="684,185 674,180 674,190" fill="#e5e5e5"/>
146+ <text x="547" y="177" fill="#a3a3a3" font-size="12.5" text-anchor="middle">3. sign in &#8594; /oauth/authorize (PKCE)</text>
147+
148+ <!-- 4: redirect back with code -->
149+ <line x1="690" y1="230" x2="416" y2="230" stroke="#e5e5e5"/>
150+ <polygon points="416,230 426,225 426,235" fill="#e5e5e5"/>
151+ <text x="553" y="222" fill="#a3a3a3" font-size="12.5" text-anchor="middle">4. redirect /connect/callback?code&amp;state</text>
152+
153+ <!-- 5: page shows code -->
154+ <rect x="310" y="252" width="200" height="34" rx="6" fill="#14301e"/>
155+ <text x="410" y="273" fill="#4ade80" font-size="12.5" text-anchor="middle">5. shows account + authorization code</text>
156+
157+ <!-- 6: user enters code -->
158+ <line x1="410" y1="316" x2="136" y2="316" stroke="#7c9cff" stroke-dasharray="5 4"/>
159+ <polygon points="136,316 146,311 146,321" fill="#7c9cff"/>
160+ <text x="273" y="308" fill="#a3a3a3" font-size="12.5" text-anchor="middle">6. user enters code in terminal</text>
161+
162+ <!-- 7: token exchange -->
163+ <line x1="130" y1="360" x2="684" y2="360" stroke="#e5e5e5"/>
164+ <polygon points="684,360 674,355 674,365" fill="#e5e5e5"/>
165+ <text x="407" y="352" fill="#a3a3a3" font-size="12.5" text-anchor="middle">7. POST /oauth/token &#123;code + verifier&#125; &#8594; access/refresh tokens</text>
166+
167+ <!-- 8: done -->
168+ <rect x="30" y="392" width="200" height="46" rx="6" fill="#14301e"/>
169+ <text x="130" y="411" fill="#4ade80" font-size="12.5" text-anchor="middle">8. store token, set desired link,</text>
170+ <text x="130" y="427" fill="#4ade80" font-size="12.5" text-anchor="middle">install relay client &#8594; Connected!</text>
171+</svg>
172+<figcaption>The verifier never leaves the box (steps 1&#8594;7), so the authorization code is worthless if observed. state/challenge ride the URL fragment &mdash; they are not secrets.</figcaption>
173+</figure>
174+
175+<div class="card">
176+<h3>Details that keep it simple</h3>
177+<ul>
178+ <li><strong>Stateless URL, no short-link service.</strong> The <code>/connect</code> page reads <code>state</code> + <code>code_challenge</code> from the URL fragment and builds the Clerk authorize URL client-side. ~100-char URL &mdash; fine to transfer into an SSH session.</li>
179+ <li><strong>State check without a backend:</strong> the callback page displays one authorization blob of <code>code.state</code>; the CLI splits it and verifies <code>state</code> matches what it generated. One line on each side, preserves the loopback flow's CSRF check.</li>
180+ <li><strong>Phishing is addressed with copy, not code:</strong> the callback page shows <em>which account</em> is being connected ("Connecting as theo@&hellip;") and warns: <em>"Only enter this code in a terminal session you started yourself."</em> No mechanism needed.</li>
181+ <li><strong>Code expiry is a non-issue:</strong> Clerk auth codes live 10 minutes &mdash; the same timeout the existing loopback flow already uses. Wrong/expired code &#8594; friendly retry that reprints the URL.</li>
182+ <li><strong>One external config step:</strong> register <code>https://app.t3.codes/connect/callback</code> as an allowed redirect URI on the existing Clerk CLI OAuth client. No new client, no new scopes.</li>
183+</ul>
184+</div>
185+
186+<h2>The phases (one PR, one commit each)</h2>
187+<p class="kv">Ordering is dependency order: each phase is independently revertable and the tree is green at every boundary. Phases 1&ndash;3 are the PR; phase 4 ships separately later.</p>
188+
189+<table>
190+ <tr><th style="width:1%">Phase</th><th>Scope</th><th>Files</th><th style="width:1%">~LOC</th></tr>
191+ <tr>
192+ <td><span class="pill core">1</span></td>
193+ <td><strong>Hosted code page</strong> (web-only, purely additive, zero risk). Two static routes modeled on <code>pair.tsx</code>: <code>/connect</code> (ensure Clerk session, then client-side redirect to authorize &mdash; it's a static SPA, no server 302) and <code>/connect/callback</code> (validate params, show account + copyable code + safety warning). Both routes guard against non-hosted deployments &mdash; redirect to <code>/</code> unless <code>isHostedStaticApp()</code>, same pattern as <code>pair.tsx</code>, since this bundle also ships in local instances. Plus the Clerk dashboard redirect-URI entry.</td>
194+ <td><code>apps/web/src/routes/connect.tsx</code><br><code>apps/web/src/routes/connect.callback.tsx</code></td>
195+ <td>~200</td>
196+ </tr>
197+ <tr>
198+ <td><span class="pill core">2</span></td>
199+ <td><strong>CLI out-of-band OAuth flow + single command.</strong> Add an out-of-band OAuth login path to <code>CliTokenManager</code> (print URL, <code>Prompt.text</code> for the code, reuse <code>exchangeToken</code>). Make bare <code>t3 connect</code> a handler = login + link (subcommands untouched). Auto-pick headless mode inside SSH sessions (<code>SSH_CONNECTION</code>/<code>SSH_TTY</code> &mdash; nothing else); <code>--headless</code> flag as manual override. Loopback stays the default on desktop &mdash; no regression.</td>
200+ <td><code>cloud/CliTokenManager.ts</code> (+60)<br><code>cloud/publicConfig.ts</code> (+10)<br><code>cli/connect.ts</code> (+60)</td>
201+ <td>~150</td>
202+ </tr>
203+ <tr>
204+ <td><span class="pill new">3</span></td>
205+ <td><strong>Background on boot &mdash; Linux first</strong> (the SSH case). One new module: pinned runtime install to <code>~/.t3/runtime/versions/&lt;v&gt;</code> + <code>current</code> symlink, systemd <em>user</em> unit with absolute node/t3 paths, <code>enable-linger</code>. y/n prompt at the end of <code>connect</code>; teardown in <code>logout</code>. Install and service-start failures must land in a log file (under <code>~/.t3/userdata/logs/</code>) whose path is printed at connect time &mdash; systemd user units fail invisibly otherwise. Unit-file generation is pure string-building &#8594; trivially testable. macOS launchd / Windows follow as 3b/3c only if wanted.</td>
206+ <td><code>cloud/bootService.ts</code> (new)<br><code>cli/connect.ts</code> (+prompt/teardown)</td>
207+ <td>~250</td>
208+ </tr>
209+ <tr>
210+ <td><span class="pill later">4</span></td>
211+ <td><strong>Web-triggered update</strong> (optional follow-up PR; not part of this one, not needed for the core UX). Web detects <code>daemon version &lt; latest-on-channel</code> using the existing version-skew surface + hosted manifest; one authenticated "update" command &mdash; client says <em>update</em>, daemon resolves/verifies the version itself (never client-specified &mdash; that would be RCE). Stage install &#8594; verify &#8594; atomic symlink swap &#8594; <code>systemctl --user restart</code>. Progress streams reuse the <code>RelayClientInstallProgressEvent</code> pattern.</td>
212+ <td>web banner + one control command + daemon update routine</td>
213+ <td>later</td>
214+ </tr>
215+</table>
216+
217+<h3>Runtime layout (phase 3)</h3>
218+<pre><code>~/.t3/runtime/
219+├── versions/0.0.27/ &larr; npm install --prefix (gets native deps right: node-pty etc.)
220+└── current -> versions/0.0.27
221+
222+~/.config/systemd/user/t3code.service &larr; ExecStart=/abs/path/node .../current/.../t3 serve
223+loginctl enable-linger $USER &larr; survives SSH logout / reboot</code></pre>
224+<p class="kv">Why a real <code>npm install</code> and not "reuse the npx binary": the npx cache is ephemeral and <code>t3</code> ships native deps (<code>node-pty</code>, <code>@ff-labs/fff-node</code>) that need per-platform prebuilds. Why pinned and not <code>npx t3@latest</code> in the unit: a boot-time registry fetch means the box may simply not come up (network down, PATH-less systemd env, nvm). Deterministic boot; updates happen out-of-band (phase 4 follow-up) or by re-running <code>npx t3 connect</code>.</p>
225+
226+<h2>Explicitly not doing</h2>
227+<ul>
228+ <li><span class="no">&#10007;</span> Relay / <code>infra</code> / contracts changes &mdash; none, in any phase</li>
229+ <li><span class="no">&#10007;</span> Short-link service (<code>app.t3.codes/c/AB7K</code>) &mdash; only matters for hand-typing; revisit if ever needed</li>
230+ <li><span class="no">&#10007;</span> RFC 8628 device grant &mdash; wrong UX direction, unverified Clerk support</li>
231+ <li><span class="no">&#10007;</span> Auto-update loop in the daemon &mdash; web-triggered only (phase 4 follow-up), user stays in control</li>
232+ <li><span class="no">&#10007;</span> Project auto-registration &mdash; workspace assumed set up; the web UI handles the rest</li>
233+ <li><span class="no">&#10007;</span> Changing existing loopback flow, subcommands, or desktop behavior</li>
234+</ul>
235+
236+<h2>Risks &amp; checks</h2>
237+<ul>
238+ <li><span class="warn">&#9888;</span> <strong>Clerk redirect URI:</strong> confirm the CLI OAuth client accepts the hosted redirect and that the token endpoint honors PKCE exchange for codes issued to it. Verify in staging before the phase 2 commit. <em>(Only external dependency in the plan.)</em></li>
239+ <li><span class="warn">&#9888;</span> <strong>systemd user env is minimal:</strong> always write absolute paths for node + t3 into the unit; never rely on PATH. Service failures are invisible by default &mdash; hence the phase 3 requirement to log to a printed file path.</li>
240+ <li><span class="warn">&#9888;</span> <strong>Linger prompt honesty:</strong> the y/n prompt should say the machine becomes reachable via T3 Connect whenever powered on &mdash; that's the feature, but say it.</li>
241+ <li><span class="ok">&#10003;</span> Re-running <code>connect</code> when linked &#8594; idempotent: refresh token, re-confirm service, done.</li>
242+</ul>
243+
244+<h2>Decision log</h2>
245+<ul class="kv">
246+ <li>Auth: hosted out-of-band OAuth redirect on Clerk PKCE (not relay-brokered pairing, not device grant) &mdash; chosen for minimal new surface.</li>
247+ <li>URL: stateless static page, no backend short-link.</li>
248+ <li>Service: real per-user login service (systemd user + linger first); detect + offer install, never silent.</li>
249+ <li>Binary: pinned managed runtime under <code>~/.t3</code>; interactive <code>npx</code> usage untouched.</li>
250+ <li>Updates: not always-latest; web UI surfaces available updates with one-click trigger (phase 4 follow-up).</li>
251+ <li>Delivery: one PR with a commit per phase (green tree at every boundary), not separate PRs.</li>
252+ <li>Workspace: assumed already set up; no auto-registration.</li>
253+</ul>
254+
255+</main>
256+</body>
257+</html>
apps/server/src/bin.test.ts+3−3
@@ -236,7 +236,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { · 235 unmodified lines
236236 }),
237237 );
238238
239− it.effect("logs in to headless connect without enabling access", () =>
239+ it.effect("accepts the --headless login override without enabling access", () =>
240240 Effect.gen(function* () {
241241 const baseDir = NodeFS.mkdtempSync(
242242 NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-login-test-"),
@@ -254,7 +254,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { · 11 unmodified lines
254254 );
255255
256256 const login = yield* captureStdout(
257− runConnectCli(["connect", "login", "--base-dir", baseDir]),
257+ runConnectCli(["connect", "login", "--base-dir", baseDir, "--headless"]),
258258 );
259259 const status = yield* captureStdout(
260260 runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]),
@@ -265,7 +265,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { · 4 unmodified lines
265265 readonly authenticated: boolean;
266266 };
267267
268− assert.equal(login.output, "Signed in to T3 Connect.");
268+ assert.equal(login.output, "✓ Signed in");
269269 assert.isFalse(decoded.desired);
270270 assert.isTrue(decoded.authenticated);
271271 }),
apps/server/src/cli/connect.test.ts+42−0
@@ -1,19 +1,45 @@
11 import * as RelayClient from "@t3tools/shared/relayClient";
22 import { assert, it } from "@effect/vitest";
33 import * as Cause from "effect/Cause";
4+import * as ConfigProvider from "effect/ConfigProvider";
45 import * as Console from "effect/Console";
56 import * as Effect from "effect/Effect";
67 import * as Exit from "effect/Exit";
78 import * as Logger from "effect/Logger";
89 import * as Option from "effect/Option";
910 import * as References from "effect/References";
11+import * as Terminal from "effect/Terminal";
1012
1113 import {
1214 acquireRelayClientForLink,
15+ formatHeadlessAuthorizationPrompt,
16+ formatRelayClientReady,
17+ headlessSessionConfig,
1318 isPublishAgentActivityEnabledValue,
19+ recoverBootServiceOffer,
1420 reportCloudDisconnectResults,
1521 } from "./connect.ts";
1622
23+it("explains how to complete headless authorization", () => {
24+ assert.equal(
25+ formatHeadlessAuthorizationPrompt("https://example.test/connect"),
26+ [
27+ "Headless authorization",
28+ "Open this URL on a device with a browser:",
29+ " https://example.test/connect",
30+ "",
31+ "After signing in, return here and enter the code shown in your browser.",
32+ ].join("\n"),
33+ );
34+});
35+
36+it("formats relay readiness without printing its installation path", () => {
37+ assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2");
38+});
39+
40+const readHeadlessSessionConfig = (env: Record<string, string>) =>
41+ headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))));
42+
1743 const managedExecutable = {
1844 status: "available",
1945 executablePath: "/tmp/cloudflared",
@@ -21,6 +47,22 @@ const managedExecutable = { · 1 unmodified line
2147 version: RelayClient.CLOUDFLARED_VERSION,
2248 } as const;
2349
50+it.effect("detects headless operation from individual SSH config values", () =>
51+ Effect.gen(function* () {
52+ assert.isFalse(yield* readHeadlessSessionConfig({}));
53+ assert.isFalse(yield* readHeadlessSessionConfig({ CI: "true" }));
54+ assert.isTrue(yield* readHeadlessSessionConfig({ SSH_CONNECTION: "client server" }));
55+ assert.isTrue(yield* readHeadlessSessionConfig({ SSH_TTY: "/dev/pts/1" }));
56+ }),
57+);
58+
59+it.effect("treats cancelling optional background setup as a successful skip", () =>
60+ Effect.gen(function* () {
61+ const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({})));
62+ assert.isFalse(result);
63+ }),
64+);
65+
2466 it.effect("does not install the relay client when the user declines the managed download", () =>
2567 Effect.gen(function* () {
2668 let installCalls = 0;
apps/server/src/cli/connect.ts+245−56
@@ -6,9 +6,12 @@ import { · 5 unmodified lines
66 } from "@t3tools/contracts";
77 import { RelayOkResponse } from "@t3tools/contracts/relay";
88 import * as RelayClient from "@t3tools/shared/relayClient";
9+import * as Terminal from "effect/Terminal";
910 import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1011 import * as Cause from "effect/Cause";
12+import * as Config from "effect/Config";
1113 import * as Console from "effect/Console";
14+import * as Crypto from "effect/Crypto";
1215 import * as Duration from "effect/Duration";
1316 import * as Effect from "effect/Effect";
1417 import * as Exit from "effect/Exit";
@@ -16,6 +19,7 @@ import * as FileSystem from "effect/FileSystem"; · 1 unmodified line
1619 import * as Layer from "effect/Layer";
1720 import * as Option from "effect/Option";
1821 import * as References from "effect/References";
22+import * as Schema from "effect/Schema";
1923 import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
2024 import {
2125 FetchHttpClient,
@@ -25,8 +29,10 @@ import { · 3 unmodified lines
2529 } from "effect/unstable/http";
2630 import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
2731
32+import packageJson from "../../package.json" with { type: "json" };
2833 import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
2934 import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
35+import * as BootService from "../cloud/bootService.ts";
3036 import * as CliState from "../cloud/CliState.ts";
3137 import * as CliTokenManager from "../cloud/CliTokenManager.ts";
3238 import {
@@ -38,6 +44,8 @@ import { relayUrlConfig } from "../cloud/publicConfig.ts"; · 5 unmodified lines
3844 import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
3945 import * as ServerConfig from "../config.ts";
4046 import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
47+import * as ExternalLauncher from "../process/externalLauncher.ts";
48+import * as ProcessRunner from "../processRunner.ts";
4149 import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
4250 import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
4351
@@ -46,6 +54,81 @@ const jsonFlag = Flag.boolean("json").pipe( · 2 unmodified lines
4654 Flag.withDefault(false),
4755 );
4856
57+const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);
58+
59+const headlessFlag = Flag.boolean("headless").pipe(
60+ Flag.withDescription("Authorize without a local browser using out-of-band OAuth."),
61+ Flag.withDefault(false),
62+);
63+
64+/**
65+ * Inside an SSH session there is no local browser to complete the loopback
66+ * OAuth callback, so out-of-band OAuth is the only flow that can work.
67+ */
68+export const headlessSessionConfig = Config.all({
69+ sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option),
70+ sshTty: Config.string("SSH_TTY").pipe(Config.option),
71+}).pipe(
72+ Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)),
73+);
74+
75+const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_oauth_code")(
76+ function* ({ authorizeUrl, validate }: CliTokenManager.OutOfBandOAuthPromptInput) {
77+ yield* Console.log(formatHeadlessAuthorizationPrompt(authorizeUrl));
78+ return yield* Prompt.run(Prompt.text({ message: "Authorization code", validate }));
79+ },
80+);
81+
82+export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string {
83+ return [
84+ "Headless authorization",
85+ "Open this URL on a device with a browser:",
86+ ` ${authorizeUrl}`,
87+ "",
88+ "After signing in, return here and enter the code shown in your browser.",
89+ ].join("\n");
90+}
91+
92+/** Returns the connected account identity, if the flow could determine one. */
93+const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
94+ readonly headless: boolean;
95+}) {
96+ const tokens = yield* CliTokenManager.CloudCliTokenManager;
97+ const useOutOfBandOAuth = options.headless || (yield* headlessSessionConfig);
98+ if (!useOutOfBandOAuth) {
99+ const authorization = yield* tokens.get;
100+ if (authorization._tag === "Authorized") {
101+ return authorization.token.identity ?? null;
102+ }
103+ yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below.");
104+ }
105+ // A stored credential whose refresh fails (revoked, expired grant) must
106+ // fall through to a fresh out-of-band authorization, not dead-end the command.
107+ const existing = yield* tokens.getExisting.pipe(
108+ Effect.catchTag("CloudCliCredentialRefreshError", () =>
109+ Console.log(
110+ "The stored T3 Connect credential could not be refreshed; signing in again.",
111+ ).pipe(Effect.as(Option.none())),
112+ ),
113+ );
114+ if (Option.isSome(existing)) {
115+ return existing.value.identity ?? null;
116+ }
117+ const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin(
118+ promptForOutOfBandOAuthCode,
119+ ).pipe(
120+ Effect.mapError((cause) =>
121+ // Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI
122+ // cancels quietly instead of dumping an authorization error.
123+ Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause)
124+ ? cause
125+ : new CliTokenManager.CloudCliAuthorizationError({ cause }),
126+ ),
127+ );
128+ yield* tokens.store(token);
129+ return identity;
130+});
131+
49132 function bytesToString(value: Uint8Array): string {
50133 return new TextDecoder().decode(value);
51134 }
@@ -313,6 +396,21 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { · 261 unmodified lines
313396 if (options.clearAuthorization) {
314397 const tokens = yield* CliTokenManager.CloudCliTokenManager;
315398 yield* tokens.clear;
399+
400+ // uninstall itself no-ops when nothing is installed (and on non-Linux),
401+ // so no status pre-check that could mask a real removal failure.
402+ const bootService = yield* BootService.BootService;
403+ yield* bootService.uninstall.pipe(
404+ Effect.tap((removed) =>
405+ removed ? Console.log("Removed the T3 Code background service.") : Effect.void,
406+ ),
407+ Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)),
408+ Effect.catch((error) =>
409+ Console.warn(`Could not remove the background service: ${error.message}`).pipe(
410+ Effect.as(false),
411+ ),
412+ ),
413+ );
316414 }
317415
318416 yield* reportCloudDisconnectResults({
@@ -326,7 +424,7 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { · 7 unmodified lines
326424 }
327425 });
328426
329−const runCloudCommand = <A, E>(
427+const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E>(
330428 flags: { readonly baseDir: Option.Option<string> },
331429 run: Effect.Effect<
332430 A,
@@ -335,6 +433,8 @@ const runCloudCommand = <A, E>( · 2 unmodified lines
335433 | CliTokenManager.CloudCliTokenManager
336434 | RelayClient.RelayClient
337435 | EnvironmentAuth.EnvironmentAuth
436+ | BootService.BootService
437+ | Crypto.Crypto
338438 | FileSystem.FileSystem
339439 | HttpClient.HttpClient
340440 | Prompt.Environment
@@ -344,37 +444,79 @@ const runCloudCommand = <A, E>( · 3 unmodified lines
344444 options?: {
345445 readonly quietLogs?: boolean;
346446 },
347−) =>
348− Effect.gen(function* () {
349− const logLevel = yield* GlobalFlag.LogLevel;
350− const config = yield* resolveCliAuthConfig(flags, logLevel);
351− const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
352− const runtimeLayer = Layer.mergeAll(
353− ServerSecretStore.layer,
354− CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)),
355− RelayClient.layerCloudflared({ baseDir: config.baseDir }),
356− EnvironmentAuth.runtimeLayer,
357− ServerEnvironment.layer,
358− headlessRelayClientTracingLayer,
359− ).pipe(
360− Layer.provideMerge(FetchHttpClient.layer),
361− Layer.provideMerge(ServerConfig.layer(config)),
362− Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
447+) {
448+ const logLevel = yield* GlobalFlag.LogLevel;
449+ const config = yield* resolveCliAuthConfig(flags, logLevel);
450+ const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
451+ const runtimeLayer = Layer.mergeAll(
452+ ServerSecretStore.layer,
453+ CliTokenManager.layer.pipe(
454+ Layer.provide(ServerSecretStore.layer),
455+ Layer.provide(ExternalLauncher.layer),
456+ ),
457+ RelayClient.layerCloudflared({ baseDir: config.baseDir }),
458+ EnvironmentAuth.runtimeLayer,
459+ ServerEnvironment.layer,
460+ BootService.layer({
461+ baseDir: config.baseDir,
462+ logsDir: config.logsDir,
463+ cliVersion: packageJson.version,
464+ }).pipe(Layer.provide(ProcessRunner.layer)),
465+ headlessRelayClientTracingLayer,
466+ ).pipe(
467+ Layer.provideMerge(FetchHttpClient.layer),
468+ Layer.provideMerge(ServerConfig.layer(config)),
469+ Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
470+ );
471+ return yield* run.pipe(Effect.provide(runtimeLayer));
472+});
473+
474+const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : "");
475+
476+export function formatRelayClientReady(version: string): string {
477+ return `✓ Relay client ready · cloudflared ${version}`;
478+}
479+
480+const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: {
481+ readonly headless: boolean;
482+ readonly publishOnly?: boolean;
483+}) {
484+ const publishOnly = options.publishOnly ?? false;
485+ if (!publishOnly) {
486+ const relayClient = yield* RelayClient.RelayClient;
487+ const installed = yield* acquireRelayClientForLink(
488+ relayClient,
489+ confirmRelayClientInstall,
490+ reportRelayClientInstallProgress,
363491 );
364− return yield* run.pipe(Effect.provide(runtimeLayer));
365− });
492+ if (Option.isNone(installed)) {
493+ yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
494+ return null;
495+ }
496+ yield* Console.log(formatRelayClientReady(installed.value.version));
497+ }
498+
499+ const identity = yield* authorizeCli(options);
500+ yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed");
501+ if (publishOnly) {
502+ const secrets = yield* ServerSecretStore.ServerSecretStore;
503+ yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
504+ }
505+ return { identity } as const;
506+});
366507
367508 const connectLoginCommand = Command.make("login", {
368509 ...projectLocationFlags,
510+ headless: headlessFlag,
369511 }).pipe(
370512 Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."),
371513 Command.withHandler((flags) =>
372514 runCloudCommand(
373515 flags,
374516 Effect.gen(function* () {
375− const tokens = yield* CliTokenManager.CloudCliTokenManager;
376− yield* tokens.get;
377− yield* Console.log("Signed in to T3 Connect.");
517+ yield* Console.log("T3 Connect\n");
518+ const identity = yield* authorizeCli(flags);
519+ yield* Console.log(`✓ Signed in${connectedAs(identity)}`);
378520 }),
379521 ),
380522 ),
@@ -382,6 +524,7 @@ const connectLoginCommand = Command.make("login", { · 1 unmodified line
382524
383525 const connectLinkCommand = Command.make("link", {
384526 ...projectLocationFlags,
527+ headless: headlessFlag,
385528 publishOnly: Flag.boolean("publish-only").pipe(
386529 Flag.withDescription(
387530 "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).",
@@ -394,41 +537,15 @@ const connectLinkCommand = Command.make("link", { · 6 unmodified lines
394537 runCloudCommand(
395538 flags,
396539 Effect.gen(function* () {
397− // A publish-only link needs no Cloudflare tunnel, so skip installing the
398− // relay client entirely.
399− if (!flags.publishOnly) {
400− const relayClient = yield* RelayClient.RelayClient;
401− const installed = yield* acquireRelayClientForLink(
402− relayClient,
403− confirmRelayClientInstall,
404− reportRelayClientInstallProgress,
405− );
406− if (Option.isNone(installed)) {
407− yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
408− return;
409− }
540+ yield* Console.log("T3 Connect\n");
541+ const linked = yield* linkEnvironmentForConnect(flags);
542+ if (linked) {
410543 yield* Console.log(
411− `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
544+ flags.publishOnly
545+ ? `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start T3 to publish agent activity (no managed tunnel).`
546+ : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`t3 serve\` to make this machine reachable.`,
412547 );
413548 }
414−
415− const tokens = yield* CliTokenManager.CloudCliTokenManager;
416− yield* tokens.get;
417− yield* CliState.setCliDesiredCloudLink(
418− true,
419− flags.publishOnly ? "publish_only" : "managed",
420− );
421− if (flags.publishOnly) {
422− // A publish-only link exists solely to publish; without the publish
423− // flag the link would be inert and the success message a lie.
424− const secrets = yield* ServerSecretStore.ServerSecretStore;
425− yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
426− }
427− yield* Console.log(
428− flags.publishOnly
429− ? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)."
430− : "This T3 environment will be available through T3 Connect the next time T3 starts.",
431− );
432549 }),
433550 ),
434551 ),
@@ -566,8 +683,80 @@ const connectLogoutCommand = Command.make("logout", { · 131 unmodified lines
566683 ),
567684 );
568685
569−export const connectCommand = Command.make("connect").pipe(
570− Command.withDescription("Manage headless T3 Connect access."),
686+const offerBootService = Effect.gen(function* () {
687+ const bootService = yield* BootService.BootService;
688+ const { supported, installed, current } = yield* bootService.status;
689+ if (!supported) {
690+ // Don't prompt for something that can only fail; background setup is
691+ // Linux/systemd-only for now.
692+ return false;
693+ }
694+ if (installed && current) {
695+ yield* Console.log("T3 Code is already set up to run in the background on this machine.");
696+ return true;
697+ }
698+ const wanted = yield* Prompt.run(
699+ Prompt.confirm({
700+ message: installed
701+ ? "The installed T3 Code background service is from an older setup. Update it now?"
702+ : "Run T3 Code in the background whenever this machine boots? " +
703+ "It stays reachable through T3 Connect even after you log out.",
704+ initial: true,
705+ }),
706+ );
707+ if (!wanted) {
708+ return false;
709+ }
710+ const plan = yield* bootService.install;
711+ yield* Console.log(`Background service installed. Logs: ${plan.logPath}`);
712+ return true;
713+});
714+
715+export const recoverBootServiceOffer = <R>(
716+ offer: Effect.Effect<boolean, BootService.BootServiceError | Terminal.QuitError, R>,
717+) =>
718+ offer.pipe(
719+ Effect.catchTags({
720+ QuitError: () => Effect.succeed(false),
721+ BootServiceUnsupportedError: (error) =>
722+ Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)),
723+ BootServiceCommandError: (error) =>
724+ Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
725+ BootServiceInstallError: (error) =>
726+ Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
727+ }),
728+ );
729+
730+export const connectCommand = Command.make("connect", {
731+ ...projectLocationFlags,
732+ headless: headlessFlag,
733+}).pipe(
734+ Command.withDescription("Set up T3 Connect for this machine."),
735+ Command.withHandler((flags) =>
736+ runCloudCommand(
737+ flags,
738+ Effect.gen(function* () {
739+ yield* Console.log("T3 Connect\n");
740+ const linked = yield* linkEnvironmentForConnect(flags);
741+ if (!linked) {
742+ return;
743+ }
744+ // Show which account was linked so an unexpected identity (an
745+ // authorization code for a different account) is visible before the
746+ // machine is brought online.
747+ yield* Console.log(`✓ Connected${connectedAs(linked.identity)}`);
748+
749+ // Connect itself already succeeded; a boot-service failure must not
750+ // fail the command, just tell the user what happened and move on.
751+ const background = yield* recoverBootServiceOffer(offerBootService);
752+ yield* Console.log(
753+ background
754+ ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out."
755+ : "\nNext\n Start the server with `t3 serve` to make this machine reachable.",
756+ );
757+ }),
758+ ),
759+ ),
571760 Command.withSubcommands([
572761 connectLoginCommand,
573762 connectLinkCommand,
apps/server/src/cloud/CliTokenManager.test.ts+264−0
@@ -0,0 +1,264 @@
1+import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import { assert, it } from "@effect/vitest";
4+import * as ConfigProvider from "effect/ConfigProvider";
5+import * as Crypto from "effect/Crypto";
6+import * as Deferred from "effect/Deferred";
7+import * as Effect from "effect/Effect";
8+import * as Encoding from "effect/Encoding";
9+import * as Layer from "effect/Layer";
10+import * as Option from "effect/Option";
11+import * as Queue from "effect/Queue";
12+import * as Schema from "effect/Schema";
13+import * as Terminal from "effect/Terminal";
14+import * as HttpClient from "effect/unstable/http/HttpClient";
15+import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
16+
17+import * as CliTokenManager from "./CliTokenManager.ts";
18+import type { OutOfBandOAuthPromptInput } from "./CliTokenManager.ts";
19+
20+// pk_test_<base64 of "clerk.example.test$">
21+const TEST_ENV = {
22+ T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==",
23+ T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_test",
24+ T3CODE_HOSTED_APP_URL: "https://hosted.example.test",
25+};
26+
27+interface RecordedTokenRequest {
28+ readonly url: string;
29+ readonly params: URLSearchParams;
30+}
31+
32+// A JWT whose payload claims { email: "theo@example.test" } (signature is not
33+// verified — the CLI only reads the claim to display the connected account).
34+const TestIdTokenHeaderJson = Schema.fromJsonString(Schema.Struct({ alg: Schema.Literal("none") }));
35+const TestIdTokenPayloadJson = Schema.fromJsonString(Schema.Struct({ email: Schema.String }));
36+const encodeTestIdTokenHeader = Schema.encodeSync(TestIdTokenHeaderJson);
37+const encodeTestIdTokenPayload = Schema.encodeSync(TestIdTokenPayloadJson);
38+const idTokenWithEmail = (() => {
39+ const header = Encoding.encodeBase64Url(encodeTestIdTokenHeader({ alg: "none" }));
40+ const payload = Encoding.encodeBase64Url(
41+ encodeTestIdTokenPayload({ email: "theo@example.test" }),
42+ );
43+ return `${header}.${payload}.`;
44+})();
45+
46+const TestTokenResponseJson = Schema.fromJsonString(
47+ Schema.Struct({
48+ access_token: Schema.String,
49+ refresh_token: Schema.String,
50+ id_token: Schema.String,
51+ expires_in: Schema.Number,
52+ token_type: Schema.String,
53+ }),
54+);
55+const encodeTestTokenResponse = Schema.encodeSync(TestTokenResponseJson);
56+
57+const makeTokenEndpointLayer = (
58+ requests: Array<RecordedTokenRequest>,
59+ options?: { readonly idToken?: string },
60+) =>
61+ Layer.succeed(
62+ HttpClient.HttpClient,
63+ HttpClient.make((request) =>
64+ Effect.sync(() => {
65+ const body =
66+ request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
67+ requests.push({ url: request.url, params: new URLSearchParams(body) });
68+ return HttpClientResponse.fromWeb(
69+ request,
70+ new Response(
71+ encodeTestTokenResponse({
72+ access_token: "access-token-1",
73+ refresh_token: "refresh-token-1",
74+ id_token: options?.idToken ?? idTokenWithEmail,
75+ expires_in: 3600,
76+ token_type: "bearer",
77+ }),
78+ { status: 200, headers: { "content-type": "application/json" } },
79+ ),
80+ );
81+ }),
82+ ),
83+ );
84+
85+const provideTestEnv = Effect.provide(
86+ ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })),
87+);
88+
89+const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError);
90+
91+class PromptRejectedError extends Schema.TaggedErrorClass<PromptRejectedError>()(
92+ "PromptRejectedError",
93+ { message: Schema.String },
94+) {}
95+
96+it("formats loopback authorization with a headless-host fallback", () => {
97+ assert.equal(
98+ CliTokenManager.formatLoopbackAuthorizationPrompt("https://clerk.example.test/authorize"),
99+ [
100+ "Open this URL to authorize T3 Connect:",
101+ " https://clerk.example.test/authorize",
102+ "",
103+ "Press \u001b[1mEnter\u001b[22m to open it in your browser.",
104+ "No browser on this device? Press \u001b[1mH\u001b[22m to switch to headless mode.",
105+ ].join("\n"),
106+ );
107+});
108+
109+const makeTestTerminal = (queue: Queue.Queue<Terminal.UserInput>) =>
110+ Terminal.make({
111+ columns: Effect.succeed(80),
112+ rows: Effect.succeed(24),
113+ readInput: Effect.succeed(Queue.asDequeue(queue)),
114+ readLine: Effect.never,
115+ display: () => Effect.void,
116+ });
117+
118+const userInput = (name: string): Terminal.UserInput => ({
119+ input: Option.some(name),
120+ key: { name, ctrl: false, meta: false, shift: name !== name.toLowerCase() },
121+});
122+
123+it.effect("opens the browser on Enter and switches the active flow on H", () =>
124+ Effect.gen(function* () {
125+ const queue = yield* Queue.make<Terminal.UserInput>();
126+ yield* Queue.offerAll(queue, [userInput("enter"), userInput("H")]);
127+ const opened: Array<string> = [];
128+
129+ const result = yield* CliTokenManager.waitForLoopbackAuthorization({
130+ authorizationUrl: "https://clerk.example.test/authorize",
131+ callback: Effect.never,
132+ terminal: makeTestTerminal(queue),
133+ launchBrowser: (url) =>
134+ Effect.sync(() => {
135+ opened.push(url);
136+ }),
137+ });
138+
139+ assert.deepEqual(opened, ["https://clerk.example.test/authorize"]);
140+ assert.deepEqual(result, { _tag: "HeadlessRequested" });
141+ }),
142+);
143+
144+it.effect("finishes normally when the browser callback wins", () =>
145+ Effect.gen(function* () {
146+ const queue = yield* Queue.make<Terminal.UserInput>();
147+ const callback = yield* Deferred.make<string>();
148+ yield* Deferred.succeed(callback, "clerk-code-123");
149+
150+ const result = yield* CliTokenManager.waitForLoopbackAuthorization({
151+ authorizationUrl: "https://clerk.example.test/authorize",
152+ callback: Deferred.await(callback),
153+ terminal: makeTestTerminal(queue),
154+ launchBrowser: () => Effect.die("browser launch should not run"),
155+ });
156+
157+ assert.deepEqual(result, { _tag: "AuthorizationCode", code: "clerk-code-123" });
158+ }),
159+);
160+
161+it.layer(NodeServices.layer)("CliTokenManager.outOfBandOAuthLogin", (it) => {
162+ it.effect("prints a hosted authorize URL and exchanges the out-of-band code with PKCE", () =>
163+ Effect.gen(function* () {
164+ const requests: Array<RecordedTokenRequest> = [];
165+ let seenAuthorizeUrl = "";
166+
167+ const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin(
168+ ({ authorizeUrl, validate }: OutOfBandOAuthPromptInput) =>
169+ Effect.gen(function* () {
170+ seenAuthorizeUrl = authorizeUrl;
171+ const request = readConnectAuthorizeRequest(new URL(authorizeUrl));
172+ assert.isNotNull(request);
173+ return yield* validate(`clerk-code-123.${request!.state}`).pipe(
174+ Effect.mapError((message) => new PromptRejectedError({ message })),
175+ );
176+ }),
177+ ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv);
178+
179+ const authorizeUrl = new URL(seenAuthorizeUrl);
180+ assert.equal(authorizeUrl.origin, "https://hosted.example.test");
181+ assert.equal(authorizeUrl.pathname, "/connect");
182+ const request = readConnectAuthorizeRequest(authorizeUrl);
183+ assert.isNotNull(request);
184+ assert.match(request!.state, /^[A-Za-z0-9_-]{22}$/);
185+
186+ assert.equal(token.accessToken, "access-token-1");
187+ assert.equal(token.refreshToken, "refresh-token-1");
188+ assert.equal(token.identity, "theo@example.test");
189+ // The id_token's email claim is surfaced so connect can show the account.
190+ assert.equal(identity, "theo@example.test");
191+
192+ assert.lengthOf(requests, 1);
193+ const exchange = requests[0]!;
194+ assert.equal(exchange.url, "https://clerk.example.test/oauth/token");
195+ assert.equal(exchange.params.get("grant_type"), "authorization_code");
196+ assert.equal(exchange.params.get("code"), "clerk-code-123");
197+ assert.equal(
198+ exchange.params.get("redirect_uri"),
199+ "https://hosted.example.test/connect/callback",
200+ );
201+ assert.equal(exchange.params.get("client_id"), "oauth_client_test");
202+ // The verifier must hash to the challenge advertised in the authorize URL.
203+ const verifier = exchange.params.get("code_verifier");
204+ assert.isNotNull(verifier);
205+ const crypto = yield* Crypto.Crypto;
206+ const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier!));
207+ assert.equal(Encoding.encodeBase64Url(digest), request!.challenge);
208+ }),
209+ );
210+
211+ it.effect("rejects out-of-band codes whose state does not match the request", () =>
212+ Effect.gen(function* () {
213+ const requests: Array<RecordedTokenRequest> = [];
214+
215+ const validationErrors: Array<string> = [];
216+ const result = yield* CliTokenManager.outOfBandOAuthLogin(
217+ ({ validate }: OutOfBandOAuthPromptInput) =>
218+ validate("clerk-code-123.wrong-state").pipe(
219+ Effect.tapError((message) => Effect.sync(() => validationErrors.push(message))),
220+ Effect.mapError((message) => new PromptRejectedError({ message })),
221+ ),
222+ ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip);
223+
224+ assert.lengthOf(requests, 0);
225+ assert.lengthOf(validationErrors, 1);
226+ assert.include(validationErrors[0], "different connect request");
227+ assert.instanceOf(result, PromptRejectedError);
228+ }),
229+ );
230+
231+ it.effect("ignores an id_token whose claims are not valid JSON", () =>
232+ Effect.gen(function* () {
233+ const requests: Array<RecordedTokenRequest> = [];
234+ const malformedIdToken = `header.${Encoding.encodeBase64Url("not-json")}.signature`;
235+
236+ const { identity } = yield* CliTokenManager.outOfBandOAuthLogin(
237+ ({ authorizeUrl }: OutOfBandOAuthPromptInput) => {
238+ const request = readConnectAuthorizeRequest(new URL(authorizeUrl));
239+ assert.isNotNull(request);
240+ return Effect.succeed(`clerk-code-123.${request!.state}`);
241+ },
242+ ).pipe(
243+ Effect.provide(makeTokenEndpointLayer(requests, { idToken: malformedIdToken })),
244+ provideTestEnv,
245+ );
246+
247+ assert.isNull(identity);
248+ assert.lengthOf(requests, 1);
249+ }),
250+ );
251+
252+ it.effect("fails without touching the token endpoint when the prompt returns garbage", () =>
253+ Effect.gen(function* () {
254+ const requests: Array<RecordedTokenRequest> = [];
255+
256+ const result = yield* CliTokenManager.outOfBandOAuthLogin(() =>
257+ Effect.succeed("not-a-connect-code"),
258+ ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip);
259+
260+ assert.lengthOf(requests, 0);
261+ assert.isTrue(isAuthorizationError(result));
262+ }),
263+ );
264+});
apps/server/src/cloud/CliTokenManager.ts+283−68
@@ -3,6 +3,7 @@ import * as NodeHttp from "node:http"; · 2 unmodified lines
33
44 import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
55 import * as Clock from "effect/Clock";
6+import * as Cause from "effect/Cause";
67 import * as Console from "effect/Console";
78 import * as Context from "effect/Context";
89 import * as Crypto from "effect/Crypto";
@@ -12,8 +13,10 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines
1213 import * as Encoding from "effect/Encoding";
1314 import * as Layer from "effect/Layer";
1415 import * as Option from "effect/Option";
16+import * as Queue from "effect/Queue";
1517 import * as Schema from "effect/Schema";
1618 import * as Semaphore from "effect/Semaphore";
19+import * as Terminal from "effect/Terminal";
1720 import * as HttpClient from "effect/unstable/http/HttpClient";
1821 import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
1922 import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
@@ -21,19 +24,105 @@ import * as HttpRouter from "effect/unstable/http/HttpRouter"; · 1 unmodified line
2124 import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
2225 import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
2326
27+import {
28+ buildConnectAuthorizeRequestUrl,
29+ buildConnectClerkAuthorizeUrl,
30+ checkConnectAuthCode,
31+ connectCallbackUrl,
32+} from "@t3tools/shared/connectAuth";
33+
2434 import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
25−import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts";
35+import * as ExternalLauncher from "../process/externalLauncher.ts";
36+import {
37+ cloudCliOAuthConfig,
38+ hostedAppUrlConfig,
39+ type CloudCliOAuthConfig,
40+} from "./publicConfig.ts";
41+import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
2642
2743 const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";
2844 const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);
2945 const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));
46+const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;
47+
48+export function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {
49+ return [
50+ "Open this URL to authorize T3 Connect:",
51+ ` ${authorizationUrl}`,
52+ "",
53+ `Press ${boldTerminalText("Enter")} to open it in your browser.`,
54+ `No browser on this device? Press ${boldTerminalText("H")} to switch to headless mode.`,
55+ ].join("\n");
56+}
57+
58+export type LoopbackAuthorizationResult =
59+ | { readonly _tag: "AuthorizationCode"; readonly code: string }
60+ | { readonly _tag: "HeadlessRequested" };
61+
62+const readLoopbackAuthorizationAction = Effect.fn(
63+ "cloud.cli_token.read_loopback_authorization_action",
64+)(function* (input: Queue.Dequeue<Terminal.UserInput, Cause.Done>) {
65+ while (true) {
66+ const event = yield* Queue.take(input).pipe(Effect.mapError(() => new Terminal.QuitError({})));
67+ const keyName = event.key.name.toLowerCase();
68+ if (!event.key.ctrl && !event.key.meta && keyName === "h") {
69+ return "headless" as const;
70+ }
71+ if (keyName === "enter" || keyName === "return") {
72+ return "open-browser" as const;
73+ }
74+ }
75+});
76+
77+export const waitForLoopbackAuthorization = Effect.fn(
78+ "cloud.cli_token.wait_for_loopback_authorization",
79+)(function* <E, R>(input: {
80+ readonly authorizationUrl: string;
81+ readonly callback: Effect.Effect<string, E, R>;
82+ readonly terminal: Terminal.Terminal;
83+ readonly launchBrowser: (
84+ url: string,
85+ ) => Effect.Effect<void, ExternalLauncher.ExternalLauncherError>;
86+}) {
87+ return yield* Effect.scoped(
88+ Effect.gen(function* () {
89+ const terminalInput = yield* input.terminal.readInput;
90+ while (true) {
91+ const result = yield* Effect.raceFirst(
92+ input.callback.pipe(
93+ Effect.map(
94+ (code): LoopbackAuthorizationResult => ({ _tag: "AuthorizationCode", code }),
95+ ),
96+ ),
97+ readLoopbackAuthorizationAction(terminalInput),
98+ );
99+ if (typeof result !== "string") {
100+ return result;
101+ }
102+ if (result === "headless") {
103+ return { _tag: "HeadlessRequested" } as const;
104+ }
105+ yield* input
106+ .launchBrowser(input.authorizationUrl)
107+ .pipe(
108+ Effect.catch(() =>
109+ Console.warn(
110+ `Could not open a browser on this device. Open the URL above manually, or press ${boldTerminalText("H")} to switch to headless mode.`,
111+ ),
112+ ),
113+ );
114+ }
115+ }),
116+ );
117+});
30118
31119 const PersistedToken = Schema.Struct({
32120 accessToken: Schema.String,
33121 refreshToken: Schema.String,
34122 expiresAtEpochMs: Schema.Number,
123+ identity: Schema.optional(Schema.String),
35124 });
36−type PersistedToken = typeof PersistedToken.Type;
125+export type PersistedToken = typeof PersistedToken.Type;
37126
38127 const PersistedTokenJson = Schema.fromJsonString(PersistedToken);
39128 const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson);
@@ -42,10 +131,39 @@ const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson); · 2 unmodified lines
42131 const OAuthTokenResponse = Schema.Struct({
43132 access_token: Schema.String,
44133 refresh_token: Schema.optional(Schema.String),
134+ id_token: Schema.optional(Schema.String),
45135 expires_in: Schema.Number,
46136 token_type: Schema.String,
47137 });
48138
139+const OidcIdentityClaimsJson = Schema.fromJsonString(
140+ Schema.Struct({
141+ email: Schema.optional(Schema.String),
142+ preferred_username: Schema.optional(Schema.String),
143+ sub: Schema.optional(Schema.String),
144+ }),
145+);
146+const decodeOidcIdentityClaimsJson = Schema.decodeUnknownOption(OidcIdentityClaimsJson);
147+
148+/**
149+ * Best-effort read of the `email` (or fallback) claim from an OIDC id_token.
150+ * Only used to show the operator which account they linked, so a malformed
151+ * token degrades to "no identity" rather than an error.
152+ */
153+function idTokenIdentity(idToken: string | undefined): string | null {
154+ if (!idToken) return null;
155+ const payload = idToken.split(".")[1];
156+ if (!payload) return null;
157+ const decoded = Encoding.decodeBase64UrlString(payload);
158+ if (decoded._tag !== "Success") return null;
159+ const claims = decodeOidcIdentityClaimsJson(decoded.success);
160+ if (Option.isNone(claims)) return null;
161+ for (const value of [claims.value.email, claims.value.preferred_username, claims.value.sub]) {
162+ if (typeof value === "string" && value.length > 0) return value;
163+ }
164+ return null;
165+}
166+
49167 export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass<CloudCliCredentialRemovalError>()(
50168 "CloudCliCredentialRemovalError",
51169 { cause: Schema.Defect() },
@@ -103,18 +221,18 @@ export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; · 51 unmodified lines
103221 export class CloudCliTokenManager extends Context.Service<
104222 CloudCliTokenManager,
105223 {
106− readonly get: Effect.Effect<PersistedToken, CloudCliTokenManagerError>;
224+ readonly get: Effect.Effect<
225+ | { readonly _tag: "Authorized"; readonly token: PersistedToken }
226+ | { readonly _tag: "HeadlessRequested" },
227+ CloudCliTokenManagerError | Terminal.QuitError
228+ >;
107229 readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>;
108230 readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>;
231+ readonly store: (token: PersistedToken) => Effect.Effect<void, CloudCliTokenManagerError>;
109232 readonly clear: Effect.Effect<void, CloudCliTokenManagerError>;
110233 }
111234 >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {}
112235
113−const wrapError =
114− <WrappedError extends CloudCliTokenManagerError>(makeError: (cause: unknown) => WrappedError) =>
115− <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, WrappedError, R> =>
116− effect.pipe(Effect.mapError(makeError));
117−
118236 function stringToBytes(value: string): Uint8Array {
119237 return new TextEncoder().encode(value);
120238 }
@@ -123,10 +241,101 @@ function bytesToString(value: Uint8Array): string { · 2 unmodified lines
123241 return new TextDecoder().decode(value);
124242 }
125243
244+const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
245+ metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint">,
246+ params: Record<string, string>,
247+) {
248+ const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
249+ const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
250+ HttpClientRequest.bodyUrlParams(params),
251+ httpClient.execute,
252+ Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)),
253+ );
254+ const now = yield* Clock.currentTimeMillis;
255+ const identity = idTokenIdentity(response.id_token);
256+ return {
257+ token: {
258+ accessToken: response.access_token,
259+ refreshToken: response.refresh_token ?? params.refresh_token ?? "",
260+ expiresAtEpochMs: now + response.expires_in * 1_000,
261+ ...(identity === null ? {} : { identity }),
262+ } satisfies PersistedToken,
263+ identity,
264+ };
265+});
266+
267+const makePkceRequest = Effect.gen(function* () {
268+ const crypto = yield* Crypto.Crypto;
269+ const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32));
270+ const challenge = Encoding.encodeBase64Url(
271+ yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)),
272+ );
273+ const state = Encoding.encodeBase64Url(yield* crypto.randomBytes(16));
274+ return { verifier, challenge, state };
275+});
276+
277+export interface OutOfBandOAuthPromptInput {
278+ readonly authorizeUrl: string;
279+ readonly validate: (value: string) => Effect.Effect<string, string>;
280+}
281+
282+/**
283+ * Out-of-band OAuth for machines without a local browser (SSH). The user
284+ * opens the hosted /connect URL elsewhere, signs in, and enters the displayed
285+ * code in this terminal. The PKCE verifier never leaves this process, so the
286+ * authorization code is useless to an observer, and the state bundled into
287+ * the blob preserves the loopback flow's CSRF check.
288+ */
289+export const outOfBandOAuthLogin = Effect.fn("cloud.cli_token.out_of_band_oauth_login")(function* <
290+ E,
291+ R,
292+>(promptForCode: (input: OutOfBandOAuthPromptInput) => Effect.Effect<string, E, R>) {
293+ const metadata = yield* cloudCliOAuthConfig;
294+ const hostedAppUrl = yield* hostedAppUrlConfig;
295+ const { verifier, challenge, state } = yield* makePkceRequest;
296+
297+ const authorizationCode = yield* promptForCode({
298+ authorizeUrl: buildConnectAuthorizeRequestUrl({ hostedAppUrl, state, challenge }),
299+ validate: (value) => {
300+ const checked = checkConnectAuthCode(value, state);
301+ return typeof checked === "string" ? Effect.fail(checked) : Effect.succeed(value);
302+ },
303+ }).pipe(
304+ // Clerk authorization codes expire on this horizon anyway; matching the
305+ // loopback flow's timeout turns an abandoned prompt into a clear error.
306+ Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
307+ Effect.catchTag("TimeoutError", (cause) =>
308+ Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
309+ ),
310+ );
311+ // promptForCode is caller-supplied, so re-check the returned value rather
312+ // than trusting that the prompt ran validate.
313+ const authCode = checkConnectAuthCode(authorizationCode, state);
314+ if (typeof authCode === "string") {
315+ return yield* new CloudCliAuthorizationError({ cause: authCode });
316+ }
317+
318+ return yield* exchangeToken(metadata, {
319+ grant_type: "authorization_code",
320+ code: authCode.code,
321+ redirect_uri: connectCallbackUrl(hostedAppUrl),
322+ client_id: metadata.clientId,
323+ code_verifier: verifier,
324+ });
325+});
326+
126327 export const make = Effect.gen(function* () {
328+ // Capture exactly the services the login/refresh flows need at build time
329+ // (matching the behavior before the out-of-band flow captured the instances), not
330+ // the whole ambient context.
127331 const crypto = yield* Crypto.Crypto;
128− const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
332+ const httpClient = yield* HttpClient.HttpClient;
333+ const services = Context.make(Crypto.Crypto, crypto).pipe(
334+ Context.add(HttpClient.HttpClient, httpClient),
335+ );
129336 const secrets = yield* ServerSecretStore.ServerSecretStore;
337+ const terminal = yield* Terminal.Terminal;
338+ const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
130339 const semaphore = yield* Semaphore.make(1);
131340 const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) {
132341 const encoded = yield* encodePersistedToken(token);
@@ -136,7 +345,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines
136345
137346 const clear = secrets
138347 .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET)
139− .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause })));
348+ .pipe(Effect.mapError((cause) => new CloudCliCredentialRemovalError({ cause })));
140349
141350 const read = Effect.fn("cloud.cli_token.read")(function* () {
142351 const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET);
@@ -144,39 +353,21 @@ export const make = Effect.gen(function* () { · 1 unmodified line
144353 return Option.some(yield* decodePersistedToken(bytesToString(encoded.value)));
145354 });
146355
147− const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
148− metadata: CloudCliOAuthConfig,
149− params: Record<string, string>,
150− ) {
151− const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
152− HttpClientRequest.bodyUrlParams(params),
153− httpClient.execute,
154− Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)),
155− );
156− const now = yield* Clock.currentTimeMillis;
157− return {
158− accessToken: response.access_token,
159− refreshToken: response.refresh_token ?? params.refresh_token ?? "",
160− expiresAtEpochMs: now + response.expires_in * 1_000,
161− } satisfies PersistedToken;
162− });
163−
164356 const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) {
165357 const metadata = yield* cloudCliOAuthConfig;
166− return yield* exchangeToken(metadata, {
358+ const { token: refreshed } = yield* exchangeToken(metadata, {
167359 grant_type: "refresh_token",
168360 refresh_token: token.refreshToken,
169361 client_id: metadata.clientId,
170362 });
363+ return refreshed.identity === undefined && token.identity !== undefined
364+ ? { ...refreshed, identity: token.identity }
365+ : refreshed;
171366 });
172367
173368 const login = Effect.fn("cloud.cli_token.login")(function* () {
174369 const metadata = yield* cloudCliOAuthConfig;
175− const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32));
176− const challenge = Encoding.encodeBase64Url(
177− yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)),
178− );
179− const state = yield* crypto.randomUUIDv4;
370+ const { verifier, challenge, state } = yield* makePkceRequest;
180371 const callback = yield* Deferred.make<string>();
181372 const callbackRoute = HttpRouter.add(
182373 "GET",
@@ -191,14 +382,7 @@ export const make = Effect.gen(function* () { · 8 unmodified lines
191382 });
192383 }
193384 yield* Deferred.succeed(callback, code);
194− return yield* HttpServerResponse.html`
195−<html>
196− <body style="font-family: sans-serif; text-align: center; margin-top: 50px;">
197− <h1>T3 Connect authorization complete</h1>
198− <p>You can close this window and return to your terminal.</p>
199− </body>
200−</html>
201−`;
385+ return HttpServerResponse.html(renderLoopbackAuthorizationCompleteHtml());
202386 }),
203387 );
204388 yield* HttpRouter.serve(callbackRoute, {
@@ -214,32 +398,37 @@ export const make = Effect.gen(function* () { · 9 unmodified lines
214398 ),
215399 Layer.build,
216400 );
217− const authorizationUrl = new URL(metadata.authorizationEndpoint);
218− authorizationUrl.searchParams.set("client_id", metadata.clientId);
219− authorizationUrl.searchParams.set("redirect_uri", metadata.redirectUri);
220− authorizationUrl.searchParams.set("response_type", "code");
221− authorizationUrl.searchParams.set("scope", metadata.scopes.join(" "));
222− authorizationUrl.searchParams.set("state", state);
223− authorizationUrl.searchParams.set("code_challenge", challenge);
224− authorizationUrl.searchParams.set("code_challenge_method", "S256");
225− yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`);
226− const code = yield* Deferred.await(callback).pipe(
227− Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
228− Effect.catchTag("TimeoutError", (cause) =>
229− Effect.fail(
230− new CloudCliAuthorizationTimeoutError({
231− cause,
232− }),
401+ const authorizationUrl = buildConnectClerkAuthorizeUrl({
402+ authorizationEndpoint: metadata.authorizationEndpoint,
403+ clientId: metadata.clientId,
404+ redirectUri: metadata.redirectUri,
405+ scopes: metadata.scopes,
406+ state,
407+ challenge,
408+ });
409+ yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl));
410+ const authorization = yield* waitForLoopbackAuthorization({
411+ authorizationUrl,
412+ callback: Deferred.await(callback).pipe(
413+ Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
414+ Effect.catchTag("TimeoutError", (cause) =>
415+ Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
233416 ),
234417 ),
235− );
236− return yield* exchangeToken(metadata, {
418+ terminal,
419+ launchBrowser: externalLauncher.launchBrowser,
420+ });
421+ if (authorization._tag === "HeadlessRequested") {
422+ return authorization;
423+ }
424+ const { token } = yield* exchangeToken(metadata, {
237425 grant_type: "authorization_code",
238− code,
426+ code: authorization.code,
239427 redirect_uri: metadata.redirectUri,
240428 client_id: metadata.clientId,
241429 code_verifier: verifier,
242430 });
431+ return { _tag: "Authorized", token } as const;
243432 });
244433
245434 const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () {
@@ -253,24 +442,50 @@ export const make = Effect.gen(function* () { · 7 unmodified lines
253442 });
254443
255444 const getExisting = semaphore.withPermits(1)(
256− getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))),
445+ getExistingNoLock().pipe(
446+ Effect.mapError((cause) => new CloudCliCredentialRefreshError({ cause })),
447+ Effect.provide(services),
448+ ),
257449 );
258450 const hasCredential = semaphore.withPermits(1)(
259451 read().pipe(
260452 Effect.map(Option.isSome),
261− wrapError((cause) => new CloudCliCredentialReadError({ cause })),
453+ Effect.mapError((cause) => new CloudCliCredentialReadError({ cause })),
262454 ),
263455 );
264456 const get = semaphore.withPermits(1)(
265457 Effect.gen(function* () {
266− const token = yield* getExistingNoLock();
267− return Option.isSome(token)
268− ? token.value
269− : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist));
270− }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))),
458+ // A stored credential that can't be read or refreshed (corrupt, revoked,
459+ // expired grant) must fall through to a fresh login rather than dead-end
460+ // the command — authorizeCli applies the same fallback to out-of-band
461+ // authorization.
462+ const token = yield* getExistingNoLock().pipe(
463+ Effect.orElseSucceed(() => Option.none<PersistedToken>()),
464+ );
465+ if (Option.isSome(token)) {
466+ return { _tag: "Authorized", token: token.value } as const;
467+ }
468+ const authorization = yield* Effect.scoped(login());
469+ return authorization._tag === "Authorized"
470+ ? ({ _tag: "Authorized", token: yield* persist(authorization.token) } as const)
471+ : authorization;
472+ }).pipe(
473+ Effect.mapError((cause) =>
474+ Terminal.isQuitError(cause) ? cause : new CloudCliAuthorizationError({ cause }),
475+ ),
476+ Effect.provide(services),
477+ ),
271478 );
479+ const store = Effect.fn("cloud.cli_token.store")(function* (token: PersistedToken) {
480+ yield* semaphore.withPermits(1)(
481+ persist(token).pipe(
482+ Effect.asVoid,
483+ Effect.mapError((cause) => new CloudCliAuthorizationError({ cause })),
484+ ),
485+ );
486+ });
272487
273− return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear });
488+ return CloudCliTokenManager.of({ get, getExisting, hasCredential, store, clear });
274489 });
275490
276491 export const layer = Layer.effect(CloudCliTokenManager, make);
apps/server/src/cloud/bootService.test.ts+529−0
@@ -0,0 +1,529 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, it } from "@effect/vitest";
3+import * as ConfigProvider from "effect/ConfigProvider";
4+import * as Effect from "effect/Effect";
5+import * as FileSystem from "effect/FileSystem";
6+import * as Layer from "effect/Layer";
7+import * as Path from "effect/Path";
8+import * as Schema from "effect/Schema";
9+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
10+
11+import {
12+ HostProcessArguments,
13+ HostProcessExecutablePath,
14+ HostProcessPlatform,
15+} from "@t3tools/shared/hostProcess";
16+
17+import * as ProcessRunner from "../processRunner.ts";
18+import * as BootService from "./bootService.ts";
19+
20+const isUnsupportedError = Schema.is(BootService.BootServiceUnsupportedError);
21+const isCommandError = Schema.is(BootService.BootServiceCommandError);
22+
23+interface RecordedCommand {
24+ readonly command: string;
25+ readonly args: ReadonlyArray<string>;
26+}
27+
28+const makeRecordingRunnerLayer = (
29+ commands: Array<RecordedCommand>,
30+ options?: {
31+ readonly failCommand?: string;
32+ readonly failWhen?: (command: string, args: ReadonlyArray<string>) => boolean;
33+ },
34+) =>
35+ Layer.succeed(
36+ ProcessRunner.ProcessRunner,
37+ ProcessRunner.ProcessRunner.of({
38+ run: (input) =>
39+ Effect.sync(() => {
40+ assert.isUndefined(input.env);
41+ commands.push({ command: input.command, args: input.args });
42+ const failed =
43+ input.command === options?.failCommand ||
44+ options?.failWhen?.(input.command, input.args) === true;
45+ return {
46+ stdout: "",
47+ stderr: failed ? `${input.command} exploded` : "",
48+ code: ChildProcessSpawner.ExitCode(failed ? 1 : 0),
49+ timedOut: false,
50+ stdoutTruncated: false,
51+ stderrTruncated: false,
52+ };
53+ }),
54+ }),
55+ );
56+
57+const makeHost = (entry: string): BootService.BootServiceHost => ({
58+ execPath: "/usr/local/bin/node",
59+ cliEntryPath: entry,
60+});
61+
62+const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") =>
63+ Effect.provide(
64+ Layer.mergeAll(
65+ Layer.succeed(HostProcessPlatform, platform),
66+ ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),
67+ ),
68+ );
69+
70+const makeTestContext = Effect.fn("test.makeTestContext")(function* () {
71+ const fs = yield* FileSystem.FileSystem;
72+ const path = yield* Path.Path;
73+ const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" });
74+ // A real file for the stable-entry cases so status can confirm the entry
75+ // point exists.
76+ const stableEntry = path.join(root, "bin.mjs");
77+ yield* fs.writeFileString(stableEntry, "#!/usr/bin/env node\n");
78+ return {
79+ fs,
80+ path,
81+ dirs: {
82+ home: root,
83+ baseDir: path.join(root, ".t3"),
84+ logsDir: path.join(root, ".t3", "userdata", "logs"),
85+ stableEntry,
86+ },
87+ };
88+});
89+
90+it("renders a systemd unit with absolute paths and append-mode logging", () => {
91+ const unit = BootService.renderBootServiceUnit({
92+ nodePath: "/usr/local/bin/node",
93+ t3EntryPath: "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs",
94+ baseDir: "/home/theo/.t3",
95+ logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
96+ unitPath: "/home/theo/.config/systemd/user/t3code.service",
97+ });
98+
99+ assert.equal(
100+ unit,
101+ [
102+ "[Unit]",
103+ "Description=T3 Code server (T3 Connect)",
104+ "StartLimitIntervalSec=300",
105+ "StartLimitBurst=5",
106+ "",
107+ "[Service]",
108+ "Type=simple",
109+ "WorkingDirectory=%h",
110+ "Environment=T3CODE_HOME=/home/theo/.t3",
111+ "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve",
112+ "Restart=always",
113+ "RestartSec=5",
114+ "StandardOutput=append:/home/theo/.t3/userdata/logs/boot-service.log",
115+ "StandardError=append:/home/theo/.t3/userdata/logs/boot-service.log",
116+ "",
117+ "[Install]",
118+ "WantedBy=default.target",
119+ "",
120+ ].join("\n"),
121+ );
122+});
123+
124+it("quotes systemd values containing spaces and escapes percent specifiers", () => {
125+ assert.equal(BootService.quoteSystemdValue("/plain/path"), "/plain/path");
126+ assert.equal(BootService.quoteSystemdValue("/home/me/T3 Data"), '"/home/me/T3 Data"');
127+ assert.equal(BootService.quoteSystemdValue("/opt/100%cpu"), "/opt/100%%cpu");
128+
129+ const unit = BootService.renderBootServiceUnit({
130+ nodePath: "/home/me/my tools/node",
131+ t3EntryPath: "/home/me/T3 Data/bin.mjs",
132+ baseDir: "/home/me/T3 Data",
133+ logPath: "/home/me/100%logs/boot.log",
134+ unitPath: "/home/me/.config/systemd/user/t3code.service",
135+ });
136+ assert.include(unit, 'ExecStart="/home/me/my tools/node" "/home/me/T3 Data/bin.mjs" serve');
137+ assert.include(unit, 'Environment=T3CODE_HOME="/home/me/T3 Data"');
138+ // append: paths take the rest of the line literally (spaces are fine,
139+ // quoting is not), but % still goes through specifier expansion.
140+ assert.include(unit, "StandardOutput=append:/home/me/100%%logs/boot.log");
141+ assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log");
142+});
143+
144+it("flags package-manager cache entry points as ephemeral", () => {
145+ assert.isTrue(
146+ BootService.isEphemeralCacheEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"),
147+ );
148+ assert.isTrue(
149+ BootService.isEphemeralCacheEntry("C:\\Users\\theo\\AppData\\npm-cache\\_npx\\abc\\bin.mjs"),
150+ );
151+ assert.isTrue(
152+ BootService.isEphemeralCacheEntry(
153+ "/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs",
154+ ),
155+ );
156+ assert.isTrue(
157+ BootService.isEphemeralCacheEntry("/home/theo/.bun/install/cache/t3@0.0.27/dist/bin.mjs"),
158+ );
159+ assert.isFalse(BootService.isEphemeralCacheEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs"));
160+ assert.isFalse(
161+ BootService.isEphemeralCacheEntry(
162+ "/home/theo/dev/pnpm/dlx-tools/t3/node_modules/t3/dist/bin.mjs",
163+ ),
164+ );
165+ assert.isFalse(
166+ BootService.isEphemeralCacheEntry(
167+ "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs",
168+ ),
169+ );
170+});
171+
172+it.layer(NodeServices.layer)("BootService", (it) => {
173+ it.effect("installs the unit, enables the service, and enables linger", () =>
174+ Effect.gen(function* () {
175+ const { dirs, fs, path } = yield* makeTestContext();
176+ const commands: Array<RecordedCommand> = [];
177+ const service = yield* BootService.make({
178+ baseDir: dirs.baseDir,
179+ logsDir: dirs.logsDir,
180+ cliVersion: "0.0.27",
181+ host: makeHost(dirs.stableEntry),
182+ }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
183+
184+ const plan = yield* service.install;
185+
186+ // A stable entry point is reused directly — no npm install.
187+ assert.equal(plan.t3EntryPath, dirs.stableEntry);
188+ assert.deepEqual(
189+ commands.map((entry) => [entry.command, ...entry.args].join(" ")),
190+ [
191+ "systemctl --user daemon-reload",
192+ "systemctl --user enable t3code.service",
193+ // restart (not enable --now) so repairing a stale unit replaces a
194+ // running process instead of leaving the old one until reboot.
195+ "systemctl --user restart t3code.service",
196+ "loginctl enable-linger",
197+ ],
198+ );
199+
200+ const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service");
201+ const unit = yield* fs.readFileString(unitPath);
202+ assert.include(unit, `ExecStart=/usr/local/bin/node ${dirs.stableEntry} serve`);
203+ assert.include(unit, `Environment=T3CODE_HOME=${dirs.baseDir}`);
204+
205+ const status = yield* service.status;
206+ assert.isTrue(status.supported);
207+ assert.isTrue(status.installed);
208+ assert.isTrue(status.current);
209+
210+ const removed = yield* service.uninstall;
211+ assert.isTrue(removed);
212+ assert.isFalse(yield* fs.exists(unitPath));
213+ const statusAfter = yield* service.status;
214+ assert.isFalse(statusAfter.installed);
215+ const removedAgain = yield* service.uninstall;
216+ assert.isFalse(removedAgain);
217+ }),
218+ );
219+
220+ it.effect("pins a runtime via npm install when running from the npx cache", () =>
221+ Effect.gen(function* () {
222+ const { dirs, fs, path } = yield* makeTestContext();
223+ const commands: Array<RecordedCommand> = [];
224+ const service = yield* BootService.make({
225+ baseDir: dirs.baseDir,
226+ logsDir: dirs.logsDir,
227+ cliVersion: "0.0.27",
228+ host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"),
229+ }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
230+
231+ const plan = yield* service.install;
232+
233+ const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27");
234+ assert.equal(
235+ plan.t3EntryPath,
236+ path.join(runtimeDir, "node_modules", "t3", "dist", "bin.mjs"),
237+ );
238+ assert.deepEqual(commands[0], {
239+ command: "npm",
240+ args: ["install", "--prefix", runtimeDir, "--no-fund", "--no-audit", "t3@0.0.27"],
241+ });
242+ // Success is recorded via a sentinel so interrupted installs re-run.
243+ assert.isTrue(yield* fs.exists(path.join(runtimeDir, ".install-complete")));
244+ }),
245+ );
246+
247+ it.effect("reinstalls a pinned runtime when its entry point is missing", () =>
248+ Effect.gen(function* () {
249+ const { dirs, fs, path } = yield* makeTestContext();
250+ const commands: Array<RecordedCommand> = [];
251+ const service = yield* BootService.make({
252+ baseDir: dirs.baseDir,
253+ logsDir: dirs.logsDir,
254+ cliVersion: "0.0.27",
255+ host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"),
256+ }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
257+
258+ const plan = yield* service.install;
259+ yield* fs.makeDirectory(path.dirname(plan.t3EntryPath), { recursive: true });
260+ yield* fs.writeFileString(plan.t3EntryPath, "#!/usr/bin/env node\n");
261+ yield* fs.remove(plan.t3EntryPath);
262+ commands.length = 0;
263+
264+ yield* service.install;
265+
266+ assert.isTrue(commands.some(({ command }) => command === "npm"));
267+ }),
268+ );
269+
270+ it.effect("reads executable metadata from host process references", () =>
271+ Effect.gen(function* () {
272+ const { dirs } = yield* makeTestContext();
273+ const commands: Array<RecordedCommand> = [];
274+ const service = yield* BootService.make({
275+ baseDir: dirs.baseDir,
276+ logsDir: dirs.logsDir,
277+ cliVersion: "0.0.27",
278+ }).pipe(
279+ Effect.provide(makeRecordingRunnerLayer(commands)),
280+ provideHostRefs(dirs.home),
281+ Effect.provideService(HostProcessExecutablePath, "/opt/node/bin/node"),
282+ Effect.provideService(HostProcessArguments, ["/opt/node/bin/node", dirs.stableEntry]),
283+ );
284+
285+ const plan = yield* service.install;
286+ assert.equal(plan.nodePath, "/opt/node/bin/node");
287+ assert.equal(plan.t3EntryPath, dirs.stableEntry);
288+ }),
289+ );
290+
291+ it.effect("cleans up and fails when the pinned runtime install fails", () =>
292+ Effect.gen(function* () {
293+ const { dirs, fs, path } = yield* makeTestContext();
294+ const commands: Array<RecordedCommand> = [];
295+ const service = yield* BootService.make({
296+ baseDir: dirs.baseDir,
297+ logsDir: dirs.logsDir,
298+ cliVersion: "0.0.27",
299+ host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"),
300+ }).pipe(
301+ Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "npm" })),
302+ provideHostRefs(dirs.home),
303+ );
304+
305+ const error = yield* service.install.pipe(Effect.flip);
306+ assert.isTrue(isCommandError(error));
307+ const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27");
308+ // The half-installed tree must not be reused by the next attempt.
309+ assert.isFalse(yield* fs.exists(runtimeDir));
310+ assert.isFalse(yield* fs.exists(path.join(runtimeDir, ".install-complete")));
311+ }),
312+ );
313+
314+ it.effect("reports an installed-but-stale unit so connect can offer a repair", () =>
315+ Effect.gen(function* () {
316+ const { dirs, fs, path } = yield* makeTestContext();
317+ const commands: Array<RecordedCommand> = [];
318+ const service = yield* BootService.make({
319+ baseDir: dirs.baseDir,
320+ logsDir: dirs.logsDir,
321+ cliVersion: "0.0.27",
322+ host: makeHost(dirs.stableEntry),
323+ }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
324+
325+ const unitDir = path.join(dirs.home, ".config", "systemd", "user");
326+ yield* fs.makeDirectory(unitDir, { recursive: true });
327+ yield* fs.writeFileString(
328+ path.join(unitDir, "t3code.service"),
329+ "[Service]\nExecStart=/old/node /old/t3 serve\n",
330+ );
331+
332+ const status = yield* service.status;
333+ assert.isTrue(status.supported);
334+ assert.isTrue(status.installed);
335+ assert.isFalse(status.current);
336+ }),
337+ );
338+
339+ it.effect("reports a current unit as stale when its entry point is gone", () =>
340+ Effect.gen(function* () {
341+ const { dirs, fs } = yield* makeTestContext();
342+ const commands: Array<RecordedCommand> = [];
343+ const service = yield* BootService.make({
344+ baseDir: dirs.baseDir,
345+ logsDir: dirs.logsDir,
346+ cliVersion: "0.0.27",
347+ host: makeHost(dirs.stableEntry),
348+ }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
349+
350+ yield* service.install;
351+ assert.isTrue((yield* service.status).current);
352+
353+ // The pinned runtime (or global bin) was deleted to reclaim space; the
354+ // unit still matches byte-for-byte but would crashloop at boot.
355+ yield* fs.remove(dirs.stableEntry);
356+ const status = yield* service.status;
357+ assert.isTrue(status.installed);
358+ assert.isFalse(status.current);
359+ }),
360+ );
361+
362+ it.effect("fails on non-Linux platforms without touching the filesystem", () =>
363+ Effect.gen(function* () {
364+ const { dirs, fs, path } = yield* makeTestContext();
365+ const commands: Array<RecordedCommand> = [];
366+ const service = yield* BootService.make({
367+ baseDir: dirs.baseDir,
368+ logsDir: dirs.logsDir,
369+ cliVersion: "0.0.27",
370+ host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
371+ }).pipe(
372+ Effect.provide(makeRecordingRunnerLayer(commands)),
373+ provideHostRefs(dirs.home, "darwin"),
374+ );
375+
376+ const error = yield* service.install.pipe(Effect.flip);
377+ assert.isTrue(isUnsupportedError(error));
378+ assert.lengthOf(commands, 0);
379+ assert.isFalse(
380+ yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")),
381+ );
382+
383+ const status = yield* service.status;
384+ assert.isFalse(status.supported);
385+ assert.isFalse(status.installed);
386+ }),
387+ );
388+
389+ it.effect("removes the unit file when an activation step fails", () =>
390+ Effect.gen(function* () {
391+ const { dirs, fs, path } = yield* makeTestContext();
392+ const commands: Array<RecordedCommand> = [];
393+ const service = yield* BootService.make({
394+ baseDir: dirs.baseDir,
395+ logsDir: dirs.logsDir,
396+ cliVersion: "0.0.27",
397+ host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
398+ }).pipe(
399+ Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "loginctl" })),
400+ provideHostRefs(dirs.home),
401+ );
402+
403+ const error = yield* service.install.pipe(Effect.flip);
404+ assert.isTrue(isCommandError(error));
405+ // A leftover unit would make the next connect report "already set up"
406+ // even though linger never happened.
407+ assert.isFalse(
408+ yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")),
409+ );
410+ const status = yield* service.status;
411+ assert.isFalse(status.installed);
412+ assert.isTrue(
413+ commands.some(
414+ ({ command, args }) =>
415+ command === "systemctl" && args.join(" ") === "--user disable --now t3code.service",
416+ ),
417+ );
418+ }),
419+ );
420+
421+ it.effect("restores the previous unit when a repair cannot activate", () =>
422+ Effect.gen(function* () {
423+ const { dirs, fs, path } = yield* makeTestContext();
424+ const initialCommands: Array<RecordedCommand> = [];
425+ const initialService = yield* BootService.make({
426+ baseDir: dirs.baseDir,
427+ logsDir: dirs.logsDir,
428+ cliVersion: "0.0.27",
429+ host: makeHost(dirs.stableEntry),
430+ }).pipe(
431+ Effect.provide(makeRecordingRunnerLayer(initialCommands)),
432+ provideHostRefs(dirs.home),
433+ );
434+ yield* initialService.install;
435+
436+ const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service");
437+ const previousUnit = yield* fs.readFileString(unitPath);
438+ const replacementEntry = path.join(dirs.home, "replacement-bin.mjs");
439+ yield* fs.writeFileString(replacementEntry, "#!/usr/bin/env node\n");
440+ const repairCommands: Array<RecordedCommand> = [];
441+ const repairService = yield* BootService.make({
442+ baseDir: dirs.baseDir,
443+ logsDir: dirs.logsDir,
444+ cliVersion: "0.0.28",
445+ host: makeHost(replacementEntry),
446+ }).pipe(
447+ Effect.provide(makeRecordingRunnerLayer(repairCommands, { failCommand: "loginctl" })),
448+ provideHostRefs(dirs.home),
449+ );
450+
451+ const error = yield* repairService.install.pipe(Effect.flip);
452+
453+ assert.isTrue(isCommandError(error));
454+ assert.equal(yield* fs.readFileString(unitPath), previousUnit);
455+ assert.isTrue(
456+ repairCommands.some(
457+ ({ command, args }) =>
458+ command === "systemctl" && args.join(" ") === "--user restart t3code.service",
459+ ),
460+ );
461+ }),
462+ );
463+
464+ it.effect("keeps the unit when stopping it during uninstall fails", () =>
465+ Effect.gen(function* () {
466+ const { dirs, fs, path } = yield* makeTestContext();
467+ const installCommands: Array<RecordedCommand> = [];
468+ const installedService = yield* BootService.make({
469+ baseDir: dirs.baseDir,
470+ logsDir: dirs.logsDir,
471+ cliVersion: "0.0.27",
472+ host: makeHost(dirs.stableEntry),
473+ }).pipe(
474+ Effect.provide(makeRecordingRunnerLayer(installCommands)),
475+ provideHostRefs(dirs.home),
476+ );
477+ yield* installedService.install;
478+
479+ const uninstallCommands: Array<RecordedCommand> = [];
480+ const failingService = yield* BootService.make({
481+ baseDir: dirs.baseDir,
482+ logsDir: dirs.logsDir,
483+ cliVersion: "0.0.27",
484+ host: makeHost(dirs.stableEntry),
485+ }).pipe(
486+ Effect.provide(
487+ makeRecordingRunnerLayer(uninstallCommands, {
488+ failWhen: (command, args) =>
489+ command === "systemctl" && args.includes("disable") && args.includes("--now"),
490+ }),
491+ ),
492+ provideHostRefs(dirs.home),
493+ );
494+
495+ const error = yield* failingService.uninstall.pipe(Effect.flip);
496+
497+ assert.isTrue(isCommandError(error));
498+ assert.isTrue(
499+ yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")),
500+ );
501+ }),
502+ );
503+
504+ it.effect("appends failed steps to the boot-service log", () =>
505+ Effect.gen(function* () {
506+ const { dirs, fs, path } = yield* makeTestContext();
507+ const commands: Array<RecordedCommand> = [];
508+ const service = yield* BootService.make({
509+ baseDir: dirs.baseDir,
510+ logsDir: dirs.logsDir,
511+ cliVersion: "0.0.27",
512+ host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
513+ }).pipe(
514+ Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "systemctl" })),
515+ provideHostRefs(dirs.home),
516+ );
517+
518+ const error = yield* service.install.pipe(Effect.flip);
519+ assert.isTrue(isCommandError(error));
520+ if (!isCommandError(error)) return;
521+ assert.equal(error.exitCode, 1);
522+ assert.equal(error.stderrLength, "systemctl exploded".length);
523+
524+ const logPath = path.join(dirs.logsDir, "boot-service.log");
525+ assert.isTrue(yield* fs.exists(logPath));
526+ assert.include(yield* fs.readFileString(logPath), "exit code 1");
527+ }),
528+ );
529+});
apps/server/src/cloud/bootService.ts+452−0
@@ -0,0 +1,452 @@
1+import * as Context from "effect/Context";
2+import * as Config from "effect/Config";
3+import * as DateTime from "effect/DateTime";
4+import * as Duration from "effect/Duration";
5+import * as Effect from "effect/Effect";
6+import * as FileSystem from "effect/FileSystem";
7+import * as Layer from "effect/Layer";
8+import * as Option from "effect/Option";
9+import * as Path from "effect/Path";
10+import * as Schema from "effect/Schema";
11+
12+import {
13+ HostProcessArguments,
14+ HostProcessExecutablePath,
15+ HostProcessPlatform,
16+} from "@t3tools/shared/hostProcess";
17+
18+import * as ProcessRunner from "../processRunner.ts";
19+
20+/**
21+ * Installs T3 Code as a per-user boot service so a connected machine stays
22+ * reachable through T3 Connect after the SSH session ends. Linux-only for
23+ * now: systemd user unit + loginctl enable-linger. The service runs a pinned
24+ * runtime installed under <baseDir>/runtime — never `npx t3`, whose cache is
25+ * ephemeral and whose registry fetch at boot would make startup depend on
26+ * the network.
27+ */
28+
29+const BOOT_SERVICE_NAME = "t3code";
30+const BOOT_RUNTIME_DIR = "runtime";
31+
32+const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
33+const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
34+
35+const EPHEMERAL_CACHE_SEGMENTS = [
36+ "/_npx/", // npx
37+ "\\_npx\\",
38+ "/pnpm/dlx/", // pnpm dlx (~/.cache/pnpm/dlx and $PNPM_HOME/.pnpm/dlx)
39+ "/.pnpm/dlx/",
40+ "/.bun/install/cache/", // bunx
41+];
42+
43+/**
44+ * `npx t3` (and pnpm dlx / bunx) run out of ephemeral package-manager
45+ * caches that can be evicted at any time — a boot service must never point
46+ * there. Global installs, repo checkouts, and the pinned runtime below are
47+ * all stable.
48+ */
49+export function isEphemeralCacheEntry(entryPath: string): boolean {
50+ return EPHEMERAL_CACHE_SEGMENTS.some((segment) => entryPath.includes(segment));
51+}
52+
53+/**
54+ * systemd expands `%` specifiers in most directive values, including the
55+ * `append:` file paths, which take the rest of the line literally and must
56+ * NOT be quoted.
57+ */
58+export function escapeSystemdSpecifiers(value: string): string {
59+ return value.replaceAll("%", "%%");
60+}
61+
62+/**
63+ * systemd word-splits ExecStart and Environment values and expands `%`
64+ * specifiers, so paths with spaces or percents must be quoted and escaped.
65+ */
66+export function quoteSystemdValue(value: string): string {
67+ const escaped = escapeSystemdSpecifiers(value);
68+ return /[\s"'\\]/.test(escaped)
69+ ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
70+ : escaped;
71+}
72+
73+export interface BootServicePlan {
74+ /** Absolute path of the node binary running this CLI. */
75+ readonly nodePath: string;
76+ /** Absolute path of the pinned t3 entry point the unit will run. */
77+ readonly t3EntryPath: string;
78+ readonly baseDir: string;
79+ readonly logPath: string;
80+ readonly unitPath: string;
81+}
82+
83+/**
84+ * Pure so it is testable byte-for-byte. systemd user units run with a
85+ * minimal environment: every path must be absolute, and the service must
86+ * not rely on PATH, nvm shims, or shell profiles. Failures land in
87+ * `logPath` because `systemctl --user` failures are otherwise invisible.
88+ */
89+export function renderBootServiceUnit(plan: BootServicePlan): string {
90+ // No After=network-online.target: it does not exist in the systemd *user*
91+ // manager, so ordering on it is silently ignored. The server retries its
92+ // relay connection, and Restart=always covers early-boot failures.
93+ return [
94+ "[Unit]",
95+ "Description=T3 Code server (T3 Connect)",
96+ // Give up after 5 crashes in 5 minutes so a persistently broken install
97+ // (deleted runtime, broken workspace) stops instead of restarting every
98+ // 5s forever and growing the unrotated append log without bound.
99+ "StartLimitIntervalSec=300",
100+ "StartLimitBurst=5",
101+ "",
102+ "[Service]",
103+ "Type=simple",
104+ "WorkingDirectory=%h",
105+ `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
106+ `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`,
107+ "Restart=always",
108+ "RestartSec=5",
109+ `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`,
110+ `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`,
111+ "",
112+ "[Install]",
113+ "WantedBy=default.target",
114+ "",
115+ ].join("\n");
116+}
117+
118+export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()(
119+ "BootServiceUnsupportedError",
120+ { platform: Schema.String },
121+) {
122+ override get message(): string {
123+ return `Background setup currently supports Linux with systemd; this machine reports '${this.platform}'.`;
124+ }
125+}
126+
127+export class BootServiceCommandError extends Schema.TaggedErrorClass<BootServiceCommandError>()(
128+ "BootServiceCommandError",
129+ {
130+ step: Schema.String,
131+ exitCode: Schema.optional(Schema.Number),
132+ stdoutLength: Schema.optional(Schema.Number),
133+ stderrLength: Schema.optional(Schema.Number),
134+ cause: Schema.optional(Schema.Defect()),
135+ },
136+) {
137+ override get message(): string {
138+ return this.exitCode === undefined
139+ ? `Background setup failed while ${this.step}.`
140+ : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`;
141+ }
142+}
143+
144+export class BootServiceInstallError extends Schema.TaggedErrorClass<BootServiceInstallError>()(
145+ "BootServiceInstallError",
146+ { cause: Schema.Defect() },
147+) {
148+ override get message(): string {
149+ return "Could not set up the T3 Code background service.";
150+ }
151+}
152+
153+export type BootServiceError =
154+ | BootServiceUnsupportedError
155+ | BootServiceCommandError
156+ | BootServiceInstallError;
157+
158+export interface BootServiceStatus {
159+ readonly supported: boolean;
160+ readonly installed: boolean;
161+ /** False when the installed unit no longer matches what install would write. */
162+ readonly current: boolean;
163+ readonly unitPath: string;
164+ readonly logPath: string;
165+}
166+
167+export class BootService extends Context.Service<
168+ BootService,
169+ {
170+ /** Installs the pinned runtime + unit, enables linger, starts the service. */
171+ readonly install: Effect.Effect<BootServicePlan, BootServiceError>;
172+ /**
173+ * Stops and removes the unit; leaves the pinned runtime for reuse.
174+ * Returns whether a unit was actually removed.
175+ */
176+ readonly uninstall: Effect.Effect<boolean, BootServiceError>;
177+ readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;
178+ }
179+>()("t3/cloud/bootService") {}
180+
181+export interface BootServiceHost {
182+ readonly execPath: string;
183+ readonly cliEntryPath: string;
184+}
185+
186+export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
187+ readonly baseDir: string;
188+ readonly logsDir: string;
189+ readonly cliVersion: string;
190+ readonly host?: BootServiceHost;
191+}) {
192+ const hostExecPath = yield* HostProcessExecutablePath;
193+ const hostArguments = yield* HostProcessArguments;
194+ const host = input.host ?? {
195+ execPath: hostExecPath,
196+ // When running the packed CLI this is dist/bin.mjs; when stable (global
197+ // install, repo checkout) the boot service runs this same artifact.
198+ cliEntryPath: hostArguments[1] ?? "",
199+ };
200+ const platform = yield* HostProcessPlatform;
201+ const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
202+ const fs = yield* FileSystem.FileSystem;
203+ const path = yield* Path.Path;
204+ const runner = yield* ProcessRunner.ProcessRunner;
205+
206+ const unitDir = path.join(homeDir, ".config", "systemd", "user");
207+ const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE);
208+ const logPath = path.join(input.logsDir, "boot-service.log");
209+ const runtimeVersionDir = path.join(
210+ input.baseDir,
211+ BOOT_RUNTIME_DIR,
212+ "versions",
213+ input.cliVersion,
214+ );
215+ const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs");
216+ const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete");
217+
218+ const requireSystemdLinux = Effect.gen(function* () {
219+ if (platform !== "linux" || homeDir === "") {
220+ return yield* new BootServiceUnsupportedError({ platform });
221+ }
222+ });
223+
224+ const runStep = Effect.fn("cloud.boot_service.run_step")(function* (
225+ step: string,
226+ command: string,
227+ args: ReadonlyArray<string>,
228+ options?: { readonly timeout?: Duration.Input },
229+ ) {
230+ return yield* runner.run({ command, args, timeout: options?.timeout }).pipe(
231+ Effect.mapError((cause) => new BootServiceCommandError({ step, cause })),
232+ Effect.filterOrFail(
233+ (result) => result.code === 0,
234+ (result) =>
235+ new BootServiceCommandError({
236+ step,
237+ exitCode: Number(result.code),
238+ stdoutLength: result.stdout.length,
239+ stderrLength: result.stderr.length,
240+ }),
241+ ),
242+ Effect.tapError((error) =>
243+ DateTime.now.pipe(
244+ Effect.flatMap((now) =>
245+ fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, {
246+ flag: "a",
247+ }),
248+ ),
249+ Effect.ignore,
250+ ),
251+ ),
252+ );
253+ });
254+
255+ /**
256+ * Ensures plannedEntryPath exists before the unit points at it. A stable
257+ * install (global bin, repo checkout) is used as-is; an ephemeral cache
258+ * entry is replaced by `npm install --prefix`-ing the exact running
259+ * version into <baseDir>/runtime/versions/<v>. A real install (not a copy
260+ * of bin.mjs) because t3 ships native deps like node-pty.
261+ */
262+ const ensurePinnedRuntime = Effect.gen(function* () {
263+ if (!isEphemeralCacheEntry(host.cliEntryPath)) {
264+ return;
265+ }
266+ // The sentinel is written only after npm exits 0. Checking the entry
267+ // file alone is not enough: npm extracts files before running native
268+ // builds (node-pty), so a killed install leaves a plausible-looking but
269+ // broken tree behind.
270+ const alreadyPinned = yield* Effect.all([
271+ fs.exists(runtimeSentinelPath),
272+ fs.exists(runtimeEntryPath),
273+ ]).pipe(
274+ Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists),
275+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
276+ );
277+ if (alreadyPinned) {
278+ return;
279+ }
280+ yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(
281+ Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })),
282+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
283+ );
284+ yield* runStep(
285+ "installing the pinned t3 runtime (this can take a few minutes)",
286+ "npm",
287+ [
288+ "install",
289+ "--prefix",
290+ runtimeVersionDir,
291+ "--no-fund",
292+ "--no-audit",
293+ `t3@${input.cliVersion}`,
294+ ],
295+ // Native deps (node-pty) can compile from source on slow boxes; the
296+ // ProcessRunner default of 60s would kill a healthy install.
297+ { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT },
298+ ).pipe(
299+ Effect.tapError(() =>
300+ fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore),
301+ ),
302+ );
303+ yield* fs
304+ .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`)
305+ .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
306+ });
307+
308+ // Where the unit will point: derivable without touching the network, so
309+ // status can compare units purely; install materializes it first.
310+ const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath)
311+ ? runtimeEntryPath
312+ : host.cliEntryPath;
313+ const plan: BootServicePlan = {
314+ nodePath: host.execPath,
315+ t3EntryPath: plannedEntryPath,
316+ baseDir: input.baseDir,
317+ logPath,
318+ unitPath,
319+ };
320+
321+ const install: BootService["Service"]["install"] = Effect.gen(function* () {
322+ yield* requireSystemdLinux;
323+ yield* fs
324+ .makeDirectory(input.logsDir, { recursive: true })
325+ .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
326+
327+ yield* ensurePinnedRuntime;
328+
329+ const previousUnit = yield* fs.exists(unitPath).pipe(
330+ Effect.flatMap((exists) =>
331+ exists
332+ ? fs.readFileString(unitPath).pipe(Effect.map(Option.some))
333+ : Effect.succeed(Option.none<string>()),
334+ ),
335+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
336+ );
337+
338+ yield* fs.makeDirectory(unitDir, { recursive: true }).pipe(
339+ Effect.andThen(fs.writeFileString(unitPath, renderBootServiceUnit(plan))),
340+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
341+ );
342+
343+ // If any activation step fails, remove the unit again: a leftover file
344+ // would make the next `t3 connect` report the service as already set up
345+ // even though it was never enabled or lingered.
346+ yield* Effect.gen(function* () {
347+ yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);
348+ yield* runStep("enabling the service", "systemctl", [
349+ "--user",
350+ "enable",
351+ BOOT_SERVICE_UNIT_FILE,
352+ ]);
353+ // restart rather than enable --now: --now does not replace an already
354+ // running process, so repairing a stale unit would leave the old
355+ // server running until reboot. restart also starts a stopped service.
356+ yield* runStep("starting the service", "systemctl", [
357+ "--user",
358+ "restart",
359+ BOOT_SERVICE_UNIT_FILE,
360+ ]);
361+ // Linger keeps the user manager (and this service) running without an
362+ // open session — the whole point on a box reached over SSH. No
363+ // username argument: loginctl defaults to the calling user, which is
364+ // always right, while $USER can be stale (su without -l) or unset.
365+ yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]);
366+ }).pipe(Effect.tapError(() => rollbackFailedInstall(previousUnit)));
367+
368+ return plan;
369+ }).pipe(Effect.withSpan("cloud.boot_service.install"));
370+
371+ // If activation fails partway (e.g. enable succeeds but restart/linger
372+ // fails), leave nothing behind: disable removes the enable symlink, remove
373+ // deletes the file, daemon-reload clears the stale definition — otherwise a
374+ // dangling wants/ symlink logs "Failed to load unit" at every boot and the
375+ // next connect misreports the state.
376+ const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* (
377+ previousUnit: Option.Option<string>,
378+ ) {
379+ if (Option.isSome(previousUnit)) {
380+ yield* fs.writeFileString(unitPath, previousUnit.value).pipe(Effect.ignore);
381+ } else {
382+ yield* runStep("cleaning up the service", "systemctl", [
383+ "--user",
384+ "disable",
385+ "--now",
386+ BOOT_SERVICE_UNIT_FILE,
387+ ]).pipe(Effect.ignore);
388+ yield* fs.remove(unitPath).pipe(Effect.ignore);
389+ }
390+ yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]).pipe(
391+ Effect.ignore,
392+ );
393+ if (Option.isSome(previousUnit)) {
394+ yield* runStep("restoring the previous service", "systemctl", [
395+ "--user",
396+ "restart",
397+ BOOT_SERVICE_UNIT_FILE,
398+ ]).pipe(Effect.ignore);
399+ }
400+ });
401+
402+ const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
403+ yield* requireSystemdLinux;
404+ const exists = yield* fs
405+ .exists(unitPath)
406+ .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
407+ if (!exists) {
408+ return false;
409+ }
410+ yield* runStep("stopping the service", "systemctl", [
411+ "--user",
412+ "disable",
413+ "--now",
414+ BOOT_SERVICE_UNIT_FILE,
415+ ]);
416+ yield* fs
417+ .remove(unitPath)
418+ .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
419+ yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);
420+ return true;
421+ }).pipe(Effect.withSpan("cloud.boot_service.uninstall"));
422+
423+ const status: BootService["Service"]["status"] = Effect.gen(function* () {
424+ if (platform !== "linux" || homeDir === "") {
425+ return { supported: false, installed: false, current: false, unitPath, logPath };
426+ }
427+ const unitExists = yield* fs.exists(unitPath);
428+ if (!unitExists) {
429+ return { supported: true, installed: false, current: false, unitPath, logPath };
430+ }
431+ const unit = yield* fs.readFileString(unitPath);
432+ // A unit is current only if it matches what install would write now (an
433+ // older CLI wrote a different runtime/node path) AND the entry point it
434+ // references still exists (a pinned runtime under ~/.t3 can be deleted to
435+ // reclaim space). Either mismatch makes connect offer a repair.
436+ const entryExists = yield* fs.exists(plannedEntryPath);
437+ const current = unit === renderBootServiceUnit(plan) && entryExists;
438+ return { supported: true, installed: true, current, unitPath, logPath };
439+ }).pipe(
440+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
441+ Effect.withSpan("cloud.boot_service.status"),
442+ );
443+
444+ return BootService.of({ install, uninstall, status });
445+});
446+
447+export const layer = (input: {
448+ readonly baseDir: string;
449+ readonly logsDir: string;
450+ readonly cliVersion: string;
451+ readonly host?: BootServiceHost;
452+}) => Layer.effect(BootService, make(input));
apps/server/src/cloud/cliAuthHtml.test.ts+13−0
@@ -0,0 +1,13 @@
1+import { expect, it } from "@effect/vitest";
2+
3+import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
4+
5+it("renders the branded loopback authorization completion page", () => {
6+ const html = renderLoopbackAuthorizationCompleteHtml();
7+
8+ expect(html).toContain("T3 Code");
9+ expect(html).not.toContain("Secure terminal handoff");
10+ expect(html).toContain("You're connected");
11+ expect(html).toContain("return to the terminal");
12+ expect(html).toContain('name="viewport"');
13+});
apps/server/src/cloud/cliAuthHtml.ts+140−0
@@ -0,0 +1,140 @@
1+export function renderLoopbackAuthorizationCompleteHtml(): string {
2+ return `<!doctype html>
3+<html lang="en">
4+ <head>
5+ <meta charset="utf-8" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7+ <meta name="color-scheme" content="light dark" />
8+ <title>T3 Connect authorization complete</title>
9+ <style>
10+ :root {
11+ color-scheme: light dark;
12+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
13+ background: #f6f7f9;
14+ color: #17191f;
15+ }
16+ * { box-sizing: border-box; }
17+ body {
18+ min-height: 100vh;
19+ margin: 0;
20+ display: grid;
21+ place-items: center;
22+ padding: 32px 16px;
23+ background:
24+ radial-gradient(48rem 22rem at 50% -8rem, rgba(47, 119, 235, 0.15), transparent),
25+ #f6f7f9;
26+ }
27+ main {
28+ width: min(100%, 576px);
29+ overflow: hidden;
30+ border: 1px solid rgba(23, 25, 31, 0.1);
31+ border-radius: 20px;
32+ background: rgba(255, 255, 255, 0.94);
33+ box-shadow: 0 24px 64px rgba(16, 24, 40, 0.16);
34+ }
35+ .stage {
36+ position: relative;
37+ height: 96px;
38+ overflow: hidden;
39+ padding: 22px 24px;
40+ color: white;
41+ background: linear-gradient(145deg, #5ab8fa 0%, #347ff8 46%, #1939bd 100%);
42+ }
43+ .stage::before {
44+ content: "";
45+ position: absolute;
46+ inset: 0;
47+ opacity: 0.42;
48+ background-image:
49+ linear-gradient(rgba(234, 246, 255, 0.25) 1px, transparent 1px),
50+ linear-gradient(90deg, rgba(234, 246, 255, 0.25) 1px, transparent 1px),
51+ linear-gradient(rgba(234, 246, 255, 0.12) 1px, transparent 1px),
52+ linear-gradient(90deg, rgba(234, 246, 255, 0.12) 1px, transparent 1px);
53+ background-size: 32px 32px, 32px 32px, 8px 8px, 8px 8px;
54+ }
55+ .stage::after {
56+ content: "";
57+ position: absolute;
58+ inset: 0;
59+ background:
60+ radial-gradient(circle at 78% 20%, rgba(210, 255, 255, 0.36), transparent 34%),
61+ linear-gradient(to bottom, transparent 24%, rgba(8, 28, 89, 0.38));
62+ }
63+ .stage-content {
64+ position: relative;
65+ z-index: 1;
66+ }
67+ .brand {
68+ margin: 0;
69+ font-size: 10px;
70+ font-weight: 700;
71+ letter-spacing: 0.2em;
72+ text-transform: uppercase;
73+ }
74+ .brand { color: rgba(255, 255, 255, 0.82); }
75+ .content { padding: 30px 32px 34px; }
76+ .status {
77+ width: 42px;
78+ height: 42px;
79+ display: grid;
80+ place-items: center;
81+ margin-top: -52px;
82+ margin-bottom: 22px;
83+ border: 4px solid white;
84+ border-radius: 50%;
85+ background: #2065df;
86+ color: white;
87+ font-size: 22px;
88+ font-weight: 700;
89+ box-shadow: 0 8px 22px rgba(25, 72, 177, 0.3);
90+ }
91+ .eyebrow {
92+ margin: 0 0 8px;
93+ color: #2866cc;
94+ font-size: 10px;
95+ font-weight: 700;
96+ letter-spacing: 0.18em;
97+ text-transform: uppercase;
98+ }
99+ h1 { margin: 0; font-size: clamp(26px, 5vw, 34px); line-height: 1.12; letter-spacing: -0.035em; }
100+ .description { margin: 12px 0 0; color: #646975; font-size: 15px; line-height: 1.6; }
101+ .next {
102+ margin-top: 24px;
103+ padding: 14px 16px;
104+ border: 1px solid rgba(23, 25, 31, 0.1);
105+ border-radius: 12px;
106+ background: #f7f8fa;
107+ color: #454a55;
108+ font-size: 13px;
109+ }
110+ .next strong { color: #17191f; }
111+ @media (prefers-color-scheme: dark) {
112+ :root { background: #101115; color: #f1f3f7; }
113+ body { background: radial-gradient(48rem 22rem at 50% -8rem, rgba(55, 102, 210, 0.2), transparent), #101115; }
114+ main { border-color: rgba(255, 255, 255, 0.1); background: rgba(25, 27, 33, 0.96); }
115+ .status { border-color: #191b21; }
116+ .eyebrow { color: #77a8ff; }
117+ .description { color: #a8adb8; }
118+ .next { border-color: rgba(255, 255, 255, 0.1); background: #20232a; color: #b9bec8; }
119+ .next strong { color: #f1f3f7; }
120+ }
121+ </style>
122+ </head>
123+ <body>
124+ <main>
125+ <header class="stage">
126+ <div class="stage-content">
127+ <p class="brand">T3 Code</p>
128+ </div>
129+ </header>
130+ <section class="content">
131+ <div class="status" aria-hidden="true">✓</div>
132+ <p class="eyebrow">Browser authorization complete</p>
133+ <h1>You're connected</h1>
134+ <p class="description">The authorization code was delivered securely to your waiting terminal.</p>
135+ <div class="next"><strong>Next:</strong> return to the terminal to finish T3 Connect setup. You can close this window.</div>
136+ </section>
137+ </main>
138+ </body>
139+</html>`;
140+}
apps/server/src/cloud/http.test.ts+1−0
@@ -200,6 +200,7 @@ describe("reconcileDesiredCloudLink", () => { · 199 unmodified lines
200200 get: unusedSecretStoreOperation(),
201201 getExisting: Effect.succeed(Option.none()),
202202 hasCredential: unusedSecretStoreOperation(),
203+ store: () => unusedSecretStoreOperation(),
203204 clear: unusedSecretStoreOperation(),
204205 }),
205206 ),
apps/server/src/cloud/publicConfig.test.ts+35−0
@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines
44 import * as Result from "effect/Result";
55
66 import {
7+ hostedAppUrlConfig,
78 makeCloudCliOAuthConfig,
89 makeRelayUrlConfig,
910 resolveRelayClientTracingConfig,
@@ -47,6 +48,40 @@ it.effect("rejects an injected relay URL with a non-origin path", () => · 37 unmodified lines
4748 makeRelayUrlConfig("https://embedded.example.test/path").pipe(provideEnv({}), Effect.flip),
4849 );
4950
51+it.effect("normalizes the hosted app URL to an absolute origin", () =>
52+ Effect.gen(function* () {
53+ assert.equal(
54+ yield* hostedAppUrlConfig.pipe(
55+ provideEnv({ T3CODE_HOSTED_APP_URL: "https://nightly.app.t3.codes" }),
56+ ),
57+ "https://nightly.app.t3.codes",
58+ );
59+ assert.equal(
60+ yield* hostedAppUrlConfig.pipe(
61+ provideEnv({ T3CODE_HOSTED_APP_URL: "http://localhost:5733" }),
62+ ),
63+ "http://localhost:5733",
64+ );
65+ }),
66+);
67+
68+it.effect("rejects malformed or insecure hosted app URLs", () =>
69+ Effect.gen(function* () {
70+ for (const value of [
71+ "app.t3.codes",
72+ "http://app.t3.codes",
73+ "https://app.t3.codes/nested",
74+ "https://app.t3.codes?alias=true",
75+ ]) {
76+ const result = yield* hostedAppUrlConfig.pipe(
77+ provideEnv({ T3CODE_HOSTED_APP_URL: value }),
78+ Effect.result,
79+ );
80+ assert.isTrue(Result.isFailure(result), value);
81+ }
82+ }),
83+);
84+
5085 it.effect("derives direct Clerk OAuth endpoints from statically injected public config", () =>
5186 Effect.gen(function* () {
5287 const config = yield* makeCloudCliOAuthConfig({
apps/server/src/cloud/publicConfig.ts+40−1
@@ -1,3 +1,4 @@
1+import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
12 import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
23 import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
34 import * as Config from "effect/Config";
@@ -15,7 +16,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefi · 11 unmodified lines
1516 declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined;
1617
1718 const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback";
18−const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const;
19+const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES;
1920
2021 function validateRelayUrl(value: string) {
2122 const relayUrl = normalizeSecureRelayUrl(value);
@@ -100,6 +101,44 @@ export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { · 78 unmodified lines
100101
101102 export const relayUrlConfig = makeRelayUrlConfig();
102103
104+/**
105+ * Hosted app origin used for out-of-band OAuth on headless
106+ * machines. Overridable so staging/nightly builds can point their CLIs at a
107+ * matching hosted deployment.
108+ */
109+export const hostedAppUrlConfig = makePublicValueConfig(
110+ "T3CODE_HOSTED_APP_URL",
111+ DEFAULT_HOSTED_APP_URL,
112+).pipe(Config.mapOrFail(validateHostedAppUrl));
113+
114+function validateHostedAppUrl(value: string) {
115+ try {
116+ const url = new URL(value);
117+ const isLoopbackHttp =
118+ url.protocol === "http:" &&
119+ (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]");
120+ if (
121+ (url.protocol !== "https:" && !isLoopbackHttp) ||
122+ url.pathname !== "/" ||
123+ url.search !== "" ||
124+ url.hash !== ""
125+ ) {
126+ throw new Error("invalid hosted app origin");
127+ }
128+ return Effect.succeed(url.origin);
129+ } catch {
130+ return Effect.fail(
131+ new Config.ConfigError(
132+ new Schema.SchemaError(
133+ new SchemaIssue.InvalidValue(Option.some(value), {
134+ message: "Hosted app URL must be an absolute HTTPS origin (or HTTP loopback origin).",
135+ }),
136+ ),
137+ ),
138+ );
139+ }
140+}
141+
103142 function makePublicValueConfig(name: string, fallback: string) {
104143 const runtimeConfig = Config.nonEmptyString(name);
105144 return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe(
apps/server/src/persistence/Migrations.ts+4−8
@@ -123,15 +123,11 @@ export interface RunMigrationsOptions { · 122 unmodified lines
123123 export const runMigrations = Effect.fn("runMigrations")(function* ({
124124 toMigrationInclusive,
125125 }: RunMigrationsOptions = {}) {
126− yield* Effect.log(
127− toMigrationInclusive === undefined
128− ? "Running all migrations..."
129− : `Running migrations 1 through ${toMigrationInclusive}...`,
130− );
131126 const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) });
132− yield* Effect.log("Migrations ran successfully").pipe(
133− Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }),
134− );
127+ const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`);
128+ yield* migrations.length === 0
129+ ? Effect.logDebug("Database schema is current")
130+ : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations }));
135131 return executedMigrations;
136132 });
137133
apps/server/src/server.ts+4−1
@@ -322,7 +322,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 321 unmodified lines
322322 Layer.provideMerge(ServerSecretStore.layer),
323323 Layer.provideMerge(
324324 Layer.mergeAll(
325− CloudCliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)),
325+ CloudCliTokenManager.layer.pipe(
326+ Layer.provide(ServerSecretStore.layer),
327+ Layer.provide(ExternalLauncher.layer),
328+ ),
326329 CloudManagedEndpointRuntimeLive,
327330 ),
328331 ),
apps/web/src/cloud/connectCliAuth.test.ts+69−0
@@ -0,0 +1,69 @@
1+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2+
3+import {
4+ buildConnectCliClerkAuthorizeUrl,
5+ hasConnectCliAuthConfig,
6+ readConnectCliCallbackResult,
7+} from "./connectCliAuth";
8+
9+// Any pk_test_* key decodes to <base64 hostname>.clerk.accounts.dev.
10+const TEST_PUBLISHABLE_KEY = `pk_test_${btoa("witty-mole-42.clerk.accounts.dev$")}`;
11+
12+describe("connectCliAuth", () => {
13+ afterEach(() => {
14+ vi.unstubAllEnvs();
15+ });
16+
17+ it("requires both the publishable key and the CLI OAuth client id", () => {
18+ vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
19+ vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay");
20+ vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.com");
21+ expect(hasConnectCliAuthConfig()).toBe(false);
22+
23+ vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123");
24+ expect(hasConnectCliAuthConfig()).toBe(true);
25+ });
26+
27+ it("builds the Clerk authorize URL with the configured hosted origin's callback", () => {
28+ vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
29+ vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123");
30+ vi.stubEnv("VITE_HOSTED_APP_URL", "https://nightly.app.t3.codes");
31+
32+ const authorizeUrl = buildConnectCliClerkAuthorizeUrl({
33+ state: "state-1",
34+ challenge: "challenge-1",
35+ });
36+ expect(authorizeUrl).not.toBeNull();
37+
38+ const url = new URL(authorizeUrl!);
39+ expect(url.hostname).toBe("witty-mole-42.clerk.accounts.dev");
40+ expect(url.pathname).toBe("/oauth/authorize");
41+ expect(url.searchParams.get("redirect_uri")).toBe(
42+ "https://nightly.app.t3.codes/connect/callback",
43+ );
44+ expect(url.searchParams.get("state")).toBe("state-1");
45+ expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
46+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
47+ });
48+
49+ it("returns null when the CLI OAuth client id is not configured", () => {
50+ vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY);
51+ expect(
52+ buildConnectCliClerkAuthorizeUrl({ state: "state-1", challenge: "challenge-1" }),
53+ ).toBeNull();
54+ });
55+
56+ it("reads the code and state Clerk echoes back to the callback", () => {
57+ expect(
58+ readConnectCliCallbackResult(
59+ new URL("https://app.t3.codes/connect/callback?code=abc&state=state-1"),
60+ ),
61+ ).toEqual({ code: "abc", state: "state-1" });
62+ expect(
63+ readConnectCliCallbackResult(new URL("https://app.t3.codes/connect/callback?code=abc")),
64+ ).toBeNull();
65+ expect(
66+ readConnectCliCallbackResult(new URL("https://app.t3.codes/connect/callback?state=s")),
67+ ).toBeNull();
68+ });
69+});
apps/web/src/cloud/connectCliAuth.ts+91−0
@@ -0,0 +1,91 @@
1+import {
2+ buildConnectClerkAuthorizeUrl,
3+ connectCallbackUrl,
4+ CONNECT_OAUTH_SCOPES,
5+ type ConnectAuthorizeRequest,
6+} from "@t3tools/shared/connectAuth";
7+import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
8+
9+import { configuredHostedAppUrl, isHostedStaticApp } from "../hostedPairing";
10+import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig";
11+
12+const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state";
13+
14+export function resolveConnectCliOAuthClientId(): string | null {
15+ return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined);
16+}
17+
18+export function hasConnectCliAuthConfig(): boolean {
19+ return Boolean(
20+ resolveCloudPublicConfig().clerkPublishableKey && resolveConnectCliOAuthClientId(),
21+ );
22+}
23+
24+/**
25+ * Gate for the /connect routes: the CLI handshake only exists on the hosted
26+ * deployment (the same bundle ships inside local instances) and needs the
27+ * Clerk CLI OAuth client configured at build time.
28+ */
29+export function connectCliAuthRoutesEnabled(): boolean {
30+ return isHostedStaticApp() && hasCloudPublicConfig() && hasConnectCliAuthConfig();
31+}
32+
33+/**
34+ * Builds the Clerk authorize URL for a CLI-initiated connect request. The
35+ * state is mirrored into sessionStorage so the callback page can verify the
36+ * response matches a request this browser actually started.
37+ */
38+export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeRequest): string | null {
39+ const { clerkPublishableKey } = resolveCloudPublicConfig();
40+ const clientId = resolveConnectCliOAuthClientId();
41+ if (!clerkPublishableKey || !clientId) {
42+ return null;
43+ }
44+ return buildConnectClerkAuthorizeUrl({
45+ authorizationEndpoint: `${clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey)}/oauth/authorize`,
46+ clientId,
47+ redirectUri: connectCallbackUrl(configuredHostedAppUrl()),
48+ scopes: CONNECT_OAUTH_SCOPES,
49+ state: request.state,
50+ challenge: request.challenge,
51+ });
52+}
53+
54+export function rememberConnectCliAuthState(state: string): void {
55+ try {
56+ window.sessionStorage.setItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY, state);
57+ } catch {
58+ // Session storage can be unavailable (e.g. blocked). The callback page
59+ // then falls back to trusting the state Clerk echoed back.
60+ }
61+}
62+
63+/**
64+ * Read-only on purpose: this runs during render, where a removal would be
65+ * consumed by React's double-invoked/discarded renders (StrictMode) and
66+ * silently disable the state check. The value is not a secret and is
67+ * overwritten by the next /connect visit.
68+ */
69+export function readConnectCliAuthState(): string | null {
70+ try {
71+ return window.sessionStorage.getItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY);
72+ } catch {
73+ return null;
74+ }
75+}
76+
77+export interface ConnectCliCallbackResult {
78+ readonly code: string;
79+ readonly state: string;
80+}
81+
82+export function readConnectCliCallbackResult(
83+ url: URL = new URL(window.location.href),
84+): ConnectCliCallbackResult | null {
85+ const code = url.searchParams.get("code")?.trim() ?? "";
86+ const state = url.searchParams.get("state")?.trim() ?? "";
87+ if (!code || !state) {
88+ return null;
89+ }
90+ return { code, state };
91+}
apps/web/src/cloud/publicConfig.ts+1−1
@@ -24,7 +24,7 @@ export interface CloudPublicConfig { · 23 unmodified lines
2424 };
2525 }
2626
27−function trimNonEmpty(value: string | undefined): string | null {
27+export function trimNonEmpty(value: string | undefined): string | null {
2828 return value?.trim() || null;
2929 }
3030
apps/web/src/components/SidebarStageBackdrop.tsx+5−1
@@ -38,11 +38,15 @@ export function SidebarStageBackdrop({ variant }: { variant: SidebarStageBackdro · 37 unmodified lines
3838 aria-hidden
3939 className="sidebar-stage-backdrop pointer-events-none absolute inset-x-0 top-0 z-0 h-20 select-none overflow-hidden"
4040 >
41− {variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />}
41+ <StageBackdropArt variant={variant} />
4242 </div>
4343 );
4444 }
4545
46+export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) {
47+ return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;
48+}
49+
4650 const NIGHTLY_STARS: ReadonlyArray<{
4751 cx: number;
4852 cy: number;
apps/web/src/components/auth/AuthSurfaceShell.tsx+44−0
@@ -0,0 +1,44 @@
1+import type { ReactNode } from "react";
2+
3+import { APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../../branding";
4+import { resolveSidebarStageBackdropVariant, StageBackdropArt } from "../SidebarStageBackdrop";
5+
6+/**
7+ * Full-screen card for standalone auth pages, mirroring the pairing surface's
8+ * treatment. Used by the CLI-connect authorize and callback surfaces.
9+ */
10+export function AuthSurfaceShell({ children }: { readonly children: ReactNode }) {
11+ const stageVariant = resolveSidebarStageBackdropVariant(APP_STAGE_LABEL);
12+
13+ return (
14+ <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6">
15+ <div className="pointer-events-none absolute inset-0 opacity-80">
16+ <div className="absolute inset-x-0 top-0 h-72 bg-[radial-gradient(48rem_20rem_at_top,color-mix(in_srgb,var(--color-blue-500)_12%,transparent),transparent)]" />
17+ <div className="absolute inset-0 bg-[linear-gradient(145deg,color-mix(in_srgb,var(--background)_94%,var(--color-black))_0%,var(--background)_62%)]" />
18+ </div>
19+
20+ <section className="relative w-full max-w-xl overflow-hidden rounded-2xl border border-border/80 bg-card/94 shadow-2xl shadow-black/20 backdrop-blur-md">
21+ <header className="relative h-24 overflow-hidden bg-[linear-gradient(135deg,#1e61de,#17348e)] text-white">
22+ {stageVariant ? (
23+ <div className="absolute inset-0" aria-hidden>
24+ <StageBackdropArt variant={stageVariant} />
25+ </div>
26+ ) : (
27+ <div
28+ aria-hidden
29+ className="absolute inset-0 bg-[radial-gradient(circle_at_75%_25%,rgba(136,204,255,0.5),transparent_38%),linear-gradient(135deg,#2468df,#172f82)]"
30+ />
31+ )}
32+ <div className="absolute inset-0 bg-[linear-gradient(to_bottom,transparent_20%,rgba(7,18,55,0.46)_100%)]" />
33+ <div className="relative h-full p-5 sm:p-6">
34+ <p className="text-[10px] font-semibold tracking-[0.2em] text-white/80 uppercase">
35+ {APP_DISPLAY_NAME}
36+ </p>
37+ </div>
38+ </header>
39+
40+ <div className="p-6 sm:p-8">{children}</div>
41+ </section>
42+ </div>
43+ );
44+}
apps/web/src/components/cloud/ConnectCliAuthSurface.tsx+188−0
@@ -0,0 +1,188 @@
1+import { useAuth, useClerk, useUser } from "@clerk/react";
2+import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
3+import { useEffect, useRef, useState } from "react";
4+
5+import {
6+ buildConnectCliClerkAuthorizeUrl,
7+ readConnectCliAuthState,
8+ readConnectCliCallbackResult,
9+ rememberConnectCliAuthState,
10+} from "../../cloud/connectCliAuth";
11+import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
12+import { AuthSurfaceShell } from "../auth/AuthSurfaceShell";
13+import { Button } from "../ui/button";
14+
15+function ConnectCliAuthMessage({
16+ eyebrow,
17+ title,
18+ description,
19+}: {
20+ readonly eyebrow?: string;
21+ readonly title: string;
22+ readonly description: string;
23+}) {
24+ return (
25+ <>
26+ {eyebrow ? (
27+ <p className="text-[10px] font-semibold tracking-[0.18em] text-blue-600 uppercase dark:text-blue-400">
28+ {eyebrow}
29+ </p>
30+ ) : null}
31+ <h1 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1>
32+ <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{description}</p>
33+ </>
34+ );
35+}
36+
37+const invalidLinkMessage = {
38+ eyebrow: "Authorization request",
39+ title: "This connect link is incomplete",
40+ description:
41+ "The link is missing its authorization request. Re-run `t3 connect` in your terminal and open the freshly printed URL.",
42+} as const;
43+
44+/**
45+ * /connect: the URL a headless CLI prints. Waits for a Clerk session, then
46+ * forwards the CLI's PKCE request to Clerk's authorize endpoint.
47+ */
48+export function ConnectCliAuthorizeSurface() {
49+ const [request] = useState(() => readConnectAuthorizeRequest(new URL(window.location.href)));
50+ const clerk = useClerk();
51+ const { isLoaded, isSignedIn } = useAuth();
52+ const signInOpened = useRef(false);
53+ const redirecting = useRef(false);
54+
55+ useEffect(() => {
56+ if (!request || !isLoaded || redirecting.current) {
57+ return;
58+ }
59+ if (!isSignedIn) {
60+ if (!signInOpened.current) {
61+ signInOpened.current = true;
62+ clerk.openSignIn({ forceRedirectUrl: window.location.href });
63+ }
64+ return;
65+ }
66+ const authorizeUrl = buildConnectCliClerkAuthorizeUrl(request);
67+ if (!authorizeUrl) {
68+ return;
69+ }
70+ redirecting.current = true;
71+ rememberConnectCliAuthState(request.state);
72+ window.location.assign(authorizeUrl);
73+ }, [clerk, isLoaded, isSignedIn, request]);
74+
75+ if (!request) {
76+ return (
77+ <AuthSurfaceShell>
78+ <ConnectCliAuthMessage {...invalidLinkMessage} />
79+ </AuthSurfaceShell>
80+ );
81+ }
82+
83+ return (
84+ <AuthSurfaceShell>
85+ <ConnectCliAuthMessage
86+ eyebrow="Step 1 of 2 · Browser authorization"
87+ title="Connecting your terminal"
88+ description={
89+ isSignedIn
90+ ? "Redirecting to authorize T3 Connect for your CLI…"
91+ : "Sign in to continue authorizing T3 Connect for your CLI."
92+ }
93+ />
94+ {isLoaded && !isSignedIn ? (
95+ <div className="mt-6">
96+ <Button
97+ type="button"
98+ onClick={() => clerk.openSignIn({ forceRedirectUrl: window.location.href })}
99+ >
100+ Sign in
101+ </Button>
102+ </div>
103+ ) : null}
104+ </AuthSurfaceShell>
105+ );
106+}
107+
108+/**
109+ * /connect/callback: Clerk's redirect target. Shows the one-time code the
110+ * user enters in the waiting terminal.
111+ */
112+export function ConnectCliCallbackSurface() {
113+ const [result] = useState(readConnectCliCallbackResult);
114+ const [expectedState] = useState(readConnectCliAuthState);
115+ const { user } = useUser();
116+ const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "authentication code" });
117+
118+ if (!result) {
119+ return (
120+ <AuthSurfaceShell>
121+ <ConnectCliAuthMessage
122+ eyebrow="Step 2 of 2 · Terminal handoff"
123+ title="Authorization did not complete"
124+ description="No authorization code was returned. Re-run `t3 connect` in your terminal and try again."
125+ />
126+ </AuthSurfaceShell>
127+ );
128+ }
129+
130+ // Fail closed: the legitimate callback always lands in the same browser
131+ // that visited /connect (which recorded the state), so a missing or
132+ // mismatched state means this page was reached some other way — the CSRF
133+ // shape the state parameter exists to stop. Refuse to display a code.
134+ if (expectedState === null || expectedState !== result.state) {
135+ return (
136+ <AuthSurfaceShell>
137+ <ConnectCliAuthMessage
138+ eyebrow="Step 2 of 2 · Terminal handoff"
139+ title="This code belongs to a different request"
140+ description="This authorization response does not match a connect request started in this browser. Re-run `t3 connect` in your terminal and open the freshly printed URL in this browser."
141+ />
142+ </AuthSurfaceShell>
143+ );
144+ }
145+
146+ const accountLabel = user?.primaryEmailAddress?.emailAddress ?? user?.username ?? null;
147+ const authCode = encodeConnectAuthCode(result);
148+
149+ return (
150+ <AuthSurfaceShell>
151+ <ConnectCliAuthMessage
152+ eyebrow="Step 2 of 2 · Terminal handoff"
153+ title="Almost connected"
154+ description={
155+ accountLabel
156+ ? `Enter this code in your waiting terminal to connect it as ${accountLabel}.`
157+ : "Enter this code in your waiting terminal to finish connecting."
158+ }
159+ />
160+
161+ <div className="mt-6 overflow-hidden rounded-xl border border-border/80 bg-background/65">
162+ <div className="flex items-center justify-between border-b border-border/70 px-4 py-2.5">
163+ <span className="text-[10px] font-semibold tracking-[0.16em] text-muted-foreground uppercase">
164+ One-time authorization code
165+ </span>
166+ <span className="font-mono text-[10px] text-muted-foreground">expires shortly</span>
167+ </div>
168+ <code
169+ className="block p-4 font-mono text-sm leading-relaxed break-all select-all"
170+ data-testid="connect-auth-code"
171+ >
172+ {authCode}
173+ </code>
174+ </div>
175+
176+ <div className="mt-4 flex items-center gap-3">
177+ <Button type="button" onClick={() => copyToClipboard(authCode)}>
178+ {isCopied ? "Copied!" : "Copy authorization code"}
179+ </Button>
180+ </div>
181+
182+ <p className="mt-6 text-xs leading-relaxed text-muted-foreground">
183+ Only enter this code in a terminal session you started yourself. Anyone holding it can link
184+ their machine to your T3 Connect account while it is valid.
185+ </p>
186+ </AuthSurfaceShell>
187+ );
188+}
apps/web/src/hostedPairing.ts+2−2
@@ -1,6 +1,6 @@
1−import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl";
1+import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
22
3−const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes";
3+import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl";
44
55 export interface HostedPairingRequest {
66 readonly host: string;
apps/web/src/routeTree.gen.ts+42−0
@@ -11,6 +11,7 @@ · 10 unmodified lines
1111 import { Route as rootRouteImport } from './routes/__root'
1212 import { Route as SettingsRouteImport } from './routes/settings'
1313 import { Route as PairRouteImport } from './routes/pair'
14+import { Route as ConnectRouteImport } from './routes/connect'
1415 import { Route as ChatRouteImport } from './routes/_chat'
1516 import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1617 import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
@@ -20,6 +21,7 @@ import { Route as SettingsGeneralRouteImport } from './routes/settings.general' · 3 unmodified lines
2021 import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
2122 import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
2223 import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
24+import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
2325 import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
2426 import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
2527
@@ -33,6 +35,11 @@ const PairRoute = PairRouteImport.update({ · 7 unmodified lines
3335 path: '/pair',
3436 getParentRoute: () => rootRouteImport,
3537 } as any)
38+const ConnectRoute = ConnectRouteImport.update({
39+ id: '/connect',
40+ path: '/connect',
41+ getParentRoute: () => rootRouteImport,
42+} as any)
3643 const ChatRoute = ChatRouteImport.update({
3744 id: '/_chat',
3845 getParentRoute: () => rootRouteImport,
@@ -77,6 +84,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ · 38 unmodified lines
7784 path: '/archived',
7885 getParentRoute: () => SettingsRoute,
7986 } as any)
87+const ConnectCallbackRoute = ConnectCallbackRouteImport.update({
88+ id: '/connect_/callback',
89+ path: '/connect/callback',
90+ getParentRoute: () => rootRouteImport,
91+} as any)
8092 const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
8193 id: '/draft/$draftId',
8294 path: '/draft/$draftId',
@@ -91,8 +103,10 @@ const ChatEnvironmentIdThreadIdRoute = · 8 unmodified lines
91103
92104 export interface FileRoutesByFullPath {
93105 '/': typeof ChatIndexRoute
106+ '/connect': typeof ConnectRoute
94107 '/pair': typeof PairRoute
95108 '/settings': typeof SettingsRouteWithChildren
109+ '/connect/callback': typeof ConnectCallbackRoute
96110 '/settings/archived': typeof SettingsArchivedRoute
97111 '/settings/connections': typeof SettingsConnectionsRoute
98112 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
@@ -104,8 +118,10 @@ export interface FileRoutesByFullPath { · 5 unmodified lines
104118 '/draft/$draftId': typeof ChatDraftDraftIdRoute
105119 }
106120 export interface FileRoutesByTo {
121+ '/connect': typeof ConnectRoute
107122 '/pair': typeof PairRoute
108123 '/settings': typeof SettingsRouteWithChildren
124+ '/connect/callback': typeof ConnectCallbackRoute
109125 '/settings/archived': typeof SettingsArchivedRoute
110126 '/settings/connections': typeof SettingsConnectionsRoute
111127 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
@@ -120,8 +136,10 @@ export interface FileRoutesByTo { · 8 unmodified lines
120136 export interface FileRoutesById {
121137 __root__: typeof rootRouteImport
122138 '/_chat': typeof ChatRouteWithChildren
139+ '/connect': typeof ConnectRoute
123140 '/pair': typeof PairRoute
124141 '/settings': typeof SettingsRouteWithChildren
142+ '/connect_/callback': typeof ConnectCallbackRoute
125143 '/settings/archived': typeof SettingsArchivedRoute
126144 '/settings/connections': typeof SettingsConnectionsRoute
127145 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
@@ -137,8 +155,10 @@ export interface FileRouteTypes { · 9 unmodified lines
137155 fileRoutesByFullPath: FileRoutesByFullPath
138156 fullPaths:
139157 | '/'
158+ | '/connect'
140159 | '/pair'
141160 | '/settings'
161+ | '/connect/callback'
142162 | '/settings/archived'
143163 | '/settings/connections'
144164 | '/settings/diagnostics'
@@ -150,8 +170,10 @@ export interface FileRouteTypes { · 5 unmodified lines
150170 | '/draft/$draftId'
151171 fileRoutesByTo: FileRoutesByTo
152172 to:
173+ | '/connect'
153174 | '/pair'
154175 | '/settings'
176+ | '/connect/callback'
155177 | '/settings/archived'
156178 | '/settings/connections'
157179 | '/settings/diagnostics'
@@ -165,8 +187,10 @@ export interface FileRouteTypes { · 7 unmodified lines
165187 id:
166188 | '__root__'
167189 | '/_chat'
190+ | '/connect'
168191 | '/pair'
169192 | '/settings'
193+ | '/connect_/callback'
170194 | '/settings/archived'
171195 | '/settings/connections'
172196 | '/settings/diagnostics'
@@ -181,8 +205,10 @@ export interface FileRouteTypes { · 8 unmodified lines
181205 }
182206 export interface RootRouteChildren {
183207 ChatRoute: typeof ChatRouteWithChildren
208+ ConnectRoute: typeof ConnectRoute
184209 PairRoute: typeof PairRoute
185210 SettingsRoute: typeof SettingsRouteWithChildren
211+ ConnectCallbackRoute: typeof ConnectCallbackRoute
186212 }
187213
188214 declare module '@tanstack/react-router' {
@@ -201,6 +227,13 @@ declare module '@tanstack/react-router' { · 12 unmodified lines
201227 preLoaderRoute: typeof PairRouteImport
202228 parentRoute: typeof rootRouteImport
203229 }
230+ '/connect': {
231+ id: '/connect'
232+ path: '/connect'
233+ fullPath: '/connect'
234+ preLoaderRoute: typeof ConnectRouteImport
235+ parentRoute: typeof rootRouteImport
236+ }
204237 '/_chat': {
205238 id: '/_chat'
206239 path: ''
@@ -264,6 +297,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines
264297 preLoaderRoute: typeof SettingsArchivedRouteImport
265298 parentRoute: typeof SettingsRoute
266299 }
300+ '/connect_/callback': {
301+ id: '/connect_/callback'
302+ path: '/connect/callback'
303+ fullPath: '/connect/callback'
304+ preLoaderRoute: typeof ConnectCallbackRouteImport
305+ parentRoute: typeof rootRouteImport
306+ }
267307 '/_chat/draft/$draftId': {
268308 id: '/_chat/draft/$draftId'
269309 path: '/draft/$draftId'
@@ -321,8 +361,10 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( · 51 unmodified lines
321361
322362 const rootRouteChildren: RootRouteChildren = {
323363 ChatRoute: ChatRouteWithChildren,
364+ ConnectRoute: ConnectRoute,
324365 PairRoute: PairRoute,
325366 SettingsRoute: SettingsRouteWithChildren,
367+ ConnectCallbackRoute: ConnectCallbackRoute,
326368 }
327369 export const routeTree = rootRouteImport
328370 ._addFileChildren(rootRouteChildren)
apps/web/src/routes/__root.tsx+1−1
@@ -97,7 +97,7 @@ function RootRouteView() { · 96 unmodified lines
9797 };
9898 }, [pathname]);
9999
100− if (pathname === "/pair") {
100+ if (pathname === "/pair" || pathname === "/connect" || pathname.startsWith("/connect/")) {
101101 return (
102102 <>
103103 <DocumentTitleSync />
apps/web/src/routes/connect.tsx+13−0
@@ -0,0 +1,13 @@
1+import { createFileRoute, redirect } from "@tanstack/react-router";
2+
3+import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth";
4+import { ConnectCliAuthorizeSurface } from "../components/cloud/ConnectCliAuthSurface";
5+
6+export const Route = createFileRoute("/connect")({
7+ beforeLoad: () => {
8+ if (!connectCliAuthRoutesEnabled()) {
9+ throw redirect({ to: "/", replace: true });
10+ }
11+ },
12+ component: ConnectCliAuthorizeSurface,
13+});
apps/web/src/routes/connect_.callback.tsx+13−0
@@ -0,0 +1,13 @@
1+import { createFileRoute, redirect } from "@tanstack/react-router";
2+
3+import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth";
4+import { ConnectCliCallbackSurface } from "../components/cloud/ConnectCliAuthSurface";
5+
6+export const Route = createFileRoute("/connect_/callback")({
7+ beforeLoad: () => {
8+ if (!connectCliAuthRoutesEnabled()) {
9+ throw redirect({ to: "/", replace: true });
10+ }
11+ },
12+ component: ConnectCliCallbackSurface,
13+});
apps/web/src/vite-env.d.ts+1−0
@@ -9,6 +9,7 @@ interface ImportMetaEnv { · 8 unmodified lines
99 readonly VITE_HOSTED_APP_CHANNEL: string;
1010 readonly VITE_CLERK_PUBLISHABLE_KEY: string;
1111 readonly VITE_CLERK_JWT_TEMPLATE: string;
12+ readonly VITE_CLERK_CLI_OAUTH_CLIENT_ID: string;
1213 readonly VITE_RELAY_OTLP_TRACES_URL: string;
1314 readonly VITE_RELAY_OTLP_TRACES_DATASET: string;
1415 readonly VITE_RELAY_OTLP_TRACES_TOKEN: string;
apps/web/vite.config.ts+4−0
@@ -18,6 +18,7 @@ const configuredWsUrl = process.env.VITE_WS_URL?.trim(); · 17 unmodified lines
1818 const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
1919 const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
2020 const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
21+const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || "";
2122 const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || "";
2223 const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || "";
2324 const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || "";
@@ -121,6 +122,9 @@ export default defineConfig(() => { · 97 unmodified lines
121122 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),
122123 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),
123124 "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate),
125+ "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify(
126+ configuredClerkCliOAuthClientId,
127+ ),
124128 "import.meta.env.VITE_RELAY_OTLP_TRACES_URL": JSON.stringify(configuredRelayTracingUrl),
125129 "import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET": JSON.stringify(
126130 configuredRelayTracingDataset,
packages/shared/package.json+4−0
@@ -135,6 +135,10 @@ · 134 unmodified lines
135135 "types": "./src/cliArgs.ts",
136136 "import": "./src/cliArgs.ts"
137137 },
138+ "./connectAuth": {
139+ "types": "./src/connectAuth.ts",
140+ "import": "./src/connectAuth.ts"
141+ },
138142 "./path": {
139143 "types": "./src/path.ts",
140144 "import": "./src/path.ts"
packages/shared/src/connectAuth.test.ts+78−0
@@ -0,0 +1,78 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ buildConnectAuthorizeRequestUrl,
5+ buildConnectClerkAuthorizeUrl,
6+ connectCallbackUrl,
7+ encodeConnectAuthCode,
8+ parseConnectAuthCode,
9+ readConnectAuthorizeRequest,
10+} from "./connectAuth.ts";
11+
12+describe("connectAuth", () => {
13+ it("round-trips state and challenge through the authorize URL fragment", () => {
14+ const url = buildConnectAuthorizeRequestUrl({
15+ hostedAppUrl: "https://app.t3.codes",
16+ state: "q7mK9xV2pL4nR8sT6wYzAQ",
17+ challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
18+ });
19+ const parsed = new URL(url);
20+
21+ expect(parsed.origin).toBe("https://app.t3.codes");
22+ expect(parsed.pathname).toBe("/connect");
23+ expect(parsed.search).toBe("");
24+ expect(readConnectAuthorizeRequest(parsed)).toEqual({
25+ state: "q7mK9xV2pL4nR8sT6wYzAQ",
26+ challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
27+ });
28+ });
29+
30+ it("rejects authorize requests missing state or challenge", () => {
31+ expect(readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect"))).toBeNull();
32+ expect(
33+ readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#state=abc")),
34+ ).toBeNull();
35+ expect(
36+ readConnectAuthorizeRequest(new URL("https://app.t3.codes/connect#challenge=abc")),
37+ ).toBeNull();
38+ });
39+
40+ it("builds a PKCE authorize URL against the Clerk endpoint", () => {
41+ const url = new URL(
42+ buildConnectClerkAuthorizeUrl({
43+ authorizationEndpoint: "https://clerk.t3.codes/oauth/authorize",
44+ clientId: "oauthapp_123",
45+ redirectUri: connectCallbackUrl("https://app.t3.codes"),
46+ scopes: ["openid", "profile", "email"],
47+ state: "state-1",
48+ challenge: "challenge-1",
49+ }),
50+ );
51+
52+ expect(url.origin).toBe("https://clerk.t3.codes");
53+ expect(url.pathname).toBe("/oauth/authorize");
54+ expect(url.searchParams.get("client_id")).toBe("oauthapp_123");
55+ expect(url.searchParams.get("redirect_uri")).toBe("https://app.t3.codes/connect/callback");
56+ expect(url.searchParams.get("response_type")).toBe("code");
57+ expect(url.searchParams.get("scope")).toBe("openid profile email");
58+ expect(url.searchParams.get("state")).toBe("state-1");
59+ expect(url.searchParams.get("code_challenge")).toBe("challenge-1");
60+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
61+ });
62+
63+ it("round-trips the out-of-band authorization code and preserves dots inside it", () => {
64+ const blob = encodeConnectAuthCode({ code: "az9.code.chunk", state: "state-uuid" });
65+ expect(parseConnectAuthCode(blob)).toEqual({ code: "az9.code.chunk", state: "state-uuid" });
66+ expect(parseConnectAuthCode(` ${blob}\n`)).toEqual({
67+ code: "az9.code.chunk",
68+ state: "state-uuid",
69+ });
70+ });
71+
72+ it("rejects malformed out-of-band authorization codes", () => {
73+ expect(parseConnectAuthCode("")).toBeNull();
74+ expect(parseConnectAuthCode("no-separator")).toBeNull();
75+ expect(parseConnectAuthCode(".leading")).toBeNull();
76+ expect(parseConnectAuthCode("trailing.")).toBeNull();
77+ });
78+});
packages/shared/src/connectAuth.ts+124−0
@@ -0,0 +1,124 @@
1+import { readHashParams } from "./remote.ts";
2+
3+const CONNECT_AUTH_STATE_PARAM = "state";
4+const CONNECT_AUTH_CHALLENGE_PARAM = "challenge";
5+const CONNECT_AUTH_CODE_SEPARATOR = ".";
6+
7+const CONNECT_AUTHORIZE_PATH = "/connect";
8+const CONNECT_CALLBACK_PATH = "/connect/callback";
9+
10+/**
11+ * The CLI prints URLs against this origin and the web bundle uses it to
12+ * decide whether it is the hosted deployment — the two must agree, so the
13+ * default lives here.
14+ */
15+export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes";
16+
17+/**
18+ * Requested at authorize time by the hosted page and honored by the CLI's
19+ * token exchange; keep both sides on this single definition.
20+ */
21+export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const;
22+
23+export interface ConnectAuthorizeRequest {
24+ readonly state: string;
25+ readonly challenge: string;
26+}
27+
28+/**
29+ * The URL a headless CLI prints for the user to open on a machine with a
30+ * browser. `state` and `code_challenge` ride the fragment so they never reach
31+ * the hosted app's server or CDN logs; neither is a secret.
32+ */
33+export function buildConnectAuthorizeRequestUrl(input: {
34+ readonly hostedAppUrl: string;
35+ readonly state: string;
36+ readonly challenge: string;
37+}): string {
38+ const url = new URL(CONNECT_AUTHORIZE_PATH, input.hostedAppUrl);
39+ url.hash = new URLSearchParams([
40+ [CONNECT_AUTH_STATE_PARAM, input.state],
41+ [CONNECT_AUTH_CHALLENGE_PARAM, input.challenge],
42+ ]).toString();
43+ return url.toString();
44+}
45+
46+export function readConnectAuthorizeRequest(url: URL): ConnectAuthorizeRequest | null {
47+ const params = readHashParams(url);
48+ const state = params.get(CONNECT_AUTH_STATE_PARAM)?.trim() ?? "";
49+ const challenge = params.get(CONNECT_AUTH_CHALLENGE_PARAM)?.trim() ?? "";
50+ if (!state || !challenge) {
51+ return null;
52+ }
53+ return { state, challenge };
54+}
55+
56+export function connectCallbackUrl(hostedAppUrl: string): string {
57+ return new URL(CONNECT_CALLBACK_PATH, hostedAppUrl).toString();
58+}
59+
60+export function buildConnectClerkAuthorizeUrl(input: {
61+ readonly authorizationEndpoint: string;
62+ readonly clientId: string;
63+ readonly redirectUri: string;
64+ readonly scopes: ReadonlyArray<string>;
65+ readonly state: string;
66+ readonly challenge: string;
67+}): string {
68+ const url = new URL(input.authorizationEndpoint);
69+ url.searchParams.set("client_id", input.clientId);
70+ url.searchParams.set("redirect_uri", input.redirectUri);
71+ url.searchParams.set("response_type", "code");
72+ url.searchParams.set("scope", input.scopes.join(" "));
73+ url.searchParams.set("state", input.state);
74+ url.searchParams.set("code_challenge", input.challenge);
75+ url.searchParams.set("code_challenge_method", "S256");
76+ return url.toString();
77+}
78+
79+export interface ConnectAuthCode {
80+ readonly code: string;
81+ readonly state: string;
82+}
83+
84+/**
85+ * The single blob the hosted callback page displays and the CLI accepts.
86+ * Bundling `state` with the authorization code lets the CLI keep the loopback
87+ * flow's CSRF check without any backend: it verifies the returned state
88+ * matches the one it generated. Clerk authorization codes and the CLI's
89+ * base64url states never contain ".".
90+ */
91+export function encodeConnectAuthCode(input: ConnectAuthCode): string {
92+ return `${input.code}${CONNECT_AUTH_CODE_SEPARATOR}${input.state}`;
93+}
94+
95+/**
96+ * Validates an out-of-band authorization code against the state of the request this process
97+ * generated. Returns the parsed code or a user-facing error message; both
98+ * the prompt's live validation and the authoritative post-prompt check go
99+ * through here so they cannot drift.
100+ */
101+export function checkConnectAuthCode(
102+ blob: string,
103+ expectedState: string,
104+): ConnectAuthCode | string {
105+ const parsed = parseConnectAuthCode(blob);
106+ if (parsed === null) {
107+ return "That does not look like a T3 Connect code. Copy the full code.";
108+ }
109+ if (parsed.state !== expectedState) {
110+ return "That code belongs to a different connect request. Open the URL above and try again.";
111+ }
112+ return parsed;
113+}
114+
115+export function parseConnectAuthCode(blob: string): ConnectAuthCode | null {
116+ const trimmed = blob.trim();
117+ const separatorIndex = trimmed.lastIndexOf(CONNECT_AUTH_CODE_SEPARATOR);
118+ if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) {
119+ return null;
120+ }
121+ const code = trimmed.slice(0, separatorIndex);
122+ const state = trimmed.slice(separatorIndex + 1);
123+ return { code, state };
124+}
packages/shared/src/hostProcess.ts+14−0
@@ -30,4 +30,18 @@ export const HostProcessEnvironment = Context.Reference<NodeJS.ProcessEnv>( · 29 unmodified lines
3030 },
3131 );
3232
33+export const HostProcessExecutablePath = Context.Reference<string>(
34+ "@t3tools/shared/hostProcess/HostProcessExecutablePath",
35+ {
36+ defaultValue: () => process.execPath,
37+ },
38+);
39+
40+export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(
41+ "@t3tools/shared/hostProcess/HostProcessArguments",
42+ {
43+ defaultValue: () => process.argv,
44+ },
45+);
46+
3347 export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32");
packages/shared/src/remote.ts+1−1
@@ -5,7 +5,7 @@ const HOSTED_PAIRING_HOST_PARAM = "host"; · 4 unmodified lines
55 const HOSTED_PAIRING_LABEL_PARAM = "label";
66 const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]);
77
8−const readHashParams = (url: URL): URLSearchParams =>
8+export const readHashParams = (url: URL): URLSearchParams =>
99 new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
1010
1111 export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass<RemoteBackendUrlMissingError>()(
scripts/lib/public-config.ts+6−1
@@ -55,6 +55,7 @@ export function loadRepoEnv({ · 54 unmodified lines
5555 ...(config.clerkCliOAuthClientId
5656 ? {
5757 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: config.clerkCliOAuthClientId,
58+ VITE_CLERK_CLI_OAUTH_CLIENT_ID: config.clerkCliOAuthClientId,
5859 }
5960 : {}),
6061 ...(config.relayUrl
@@ -116,7 +117,11 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP · 55 unmodified lines
116117 "VITE_CLERK_JWT_TEMPLATE",
117118 "EXPO_PUBLIC_CLERK_JWT_TEMPLATE",
118119 ),
119− clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"),
120+ clerkCliOAuthClientId: firstNonEmpty(
121+ sources,
122+ "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID",
123+ "VITE_CLERK_CLI_OAUTH_CLIENT_ID",
124+ ),
120125 relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"),
121126 mobileOtlpTracesUrl: firstNonEmpty(
122127 sources,