authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-19 09:59:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-19 18:59:23+02:00
log7a820abfddbf77166d16614bb9bcabe8fcd2c829
treed7929e7be5ed495e32d2f1a4bd94331535c2b455
parent53e3c98a55237ab7327dc1fb55f1f7cf500560a1
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

35 files changed, 3017 insertions(+), 144 deletions(-)

.env.example+4
......@@ -18,6 +18,10 @@
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 created+257
......@@ -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>
94To 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>
96Enter your authentication code: <span class="dim">[code]</span><br><br>
97<span class="g">Connected as theo@t3.gg!</span><br><br>
98Run 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
223loginctl 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) => {
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) => {
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) => {
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
......@@ -1,19 +1,45 @@
11import * as RelayClient from "@t3tools/shared/relayClient";
22import { assert, it } from "@effect/vitest";
33import * as Cause from "effect/Cause";
4import * as ConfigProvider from "effect/ConfigProvider";
45import * as Console from "effect/Console";
56import * as Effect from "effect/Effect";
67import * as Exit from "effect/Exit";
78import * as Logger from "effect/Logger";
89import * as Option from "effect/Option";
910import * as References from "effect/References";
11import * as Terminal from "effect/Terminal";
1012
1113import {
1214 acquireRelayClientForLink,
15 formatHeadlessAuthorizationPrompt,
16 formatRelayClientReady,
17 headlessSessionConfig,
1318 isPublishAgentActivityEnabledValue,
19 recoverBootServiceOffer,
1420 reportCloudDisconnectResults,
1521} from "./connect.ts";
1622
23it("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
36it("formats relay readiness without printing its installation path", () => {
37 assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2");
38});
39
40const readHeadlessSessionConfig = (env: Record<string, string>) =>
41 headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))));
42
1743const managedExecutable = {
1844 status: "available",
1945 executablePath: "/tmp/cloudflared",
......@@ -21,6 +47,22 @@ const managedExecutable = {
2147 version: RelayClient.CLOUDFLARED_VERSION,
2248} as const;
2349
50it.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
59it.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
2466it.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 {
66} from "@t3tools/contracts";
77import { RelayOkResponse } from "@t3tools/contracts/relay";
88import * as RelayClient from "@t3tools/shared/relayClient";
9import * as Terminal from "effect/Terminal";
910import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1011import * as Cause from "effect/Cause";
12import * as Config from "effect/Config";
1113import * as Console from "effect/Console";
14import * as Crypto from "effect/Crypto";
1215import * as Duration from "effect/Duration";
1316import * as Effect from "effect/Effect";
1417import * as Exit from "effect/Exit";
......@@ -16,6 +19,7 @@ import * as FileSystem from "effect/FileSystem";
1619import * as Layer from "effect/Layer";
1720import * as Option from "effect/Option";
1821import * as References from "effect/References";
22import * as Schema from "effect/Schema";
1923import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
2024import {
2125 FetchHttpClient,
......@@ -25,8 +29,10 @@ import {
2529} from "effect/unstable/http";
2630import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
2731
32import packageJson from "../../package.json" with { type: "json" };
2833import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
2934import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
35import * as BootService from "../cloud/bootService.ts";
3036import * as CliState from "../cloud/CliState.ts";
3137import * as CliTokenManager from "../cloud/CliTokenManager.ts";
3238import {
......@@ -38,6 +44,8 @@ import { relayUrlConfig } from "../cloud/publicConfig.ts";
3844import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
3945import * as ServerConfig from "../config.ts";
4046import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
47import * as ExternalLauncher from "../process/externalLauncher.ts";
48import * as ProcessRunner from "../processRunner.ts";
4149import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
4250import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
4351
......@@ -46,6 +54,81 @@ const jsonFlag = Flag.boolean("json").pipe(
4654 Flag.withDefault(false),
4755);
4856
57const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);
58
59const 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 */
68export 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
75const 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
82export 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. */
93const 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
49132function bytesToString(value: Uint8Array): string {
50133 return new TextDecoder().decode(value);
51134}
......@@ -313,6 +396,21 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: {
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: {
326424 }
327425});
328426
329const runCloudCommand = <A, E>(
427const 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>(
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>(
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
474const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : "");
475
476export function formatRelayClientReady(version: string): string {
477 return `✓ Relay client ready · cloudflared ${version}`;
478}
479
480const 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
367508const 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", {
382524
383525const 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", {
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", {
566683 ),
567684);
568685
569export const connectCommand = Command.make("connect").pipe(
570 Command.withDescription("Manage headless T3 Connect access."),
686const 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
715export 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
730export 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 created+264
......@@ -0,0 +1,264 @@
1import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Crypto from "effect/Crypto";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Encoding from "effect/Encoding";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Queue from "effect/Queue";
12import * as Schema from "effect/Schema";
13import * as Terminal from "effect/Terminal";
14import * as HttpClient from "effect/unstable/http/HttpClient";
15import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
16
17import * as CliTokenManager from "./CliTokenManager.ts";
18import type { OutOfBandOAuthPromptInput } from "./CliTokenManager.ts";
19
20// pk_test_<base64 of "clerk.example.test$">
21const 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
27interface 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).
34const TestIdTokenHeaderJson = Schema.fromJsonString(Schema.Struct({ alg: Schema.Literal("none") }));
35const TestIdTokenPayloadJson = Schema.fromJsonString(Schema.Struct({ email: Schema.String }));
36const encodeTestIdTokenHeader = Schema.encodeSync(TestIdTokenHeaderJson);
37const encodeTestIdTokenPayload = Schema.encodeSync(TestIdTokenPayloadJson);
38const 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
46const 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);
55const encodeTestTokenResponse = Schema.encodeSync(TestTokenResponseJson);
56
57const 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
85const provideTestEnv = Effect.provide(
86 ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })),
87);
88
89const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError);
90
91class PromptRejectedError extends Schema.TaggedErrorClass<PromptRejectedError>()(
92 "PromptRejectedError",
93 { message: Schema.String },
94) {}
95
96it("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
109const 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
118const userInput = (name: string): Terminal.UserInput => ({
119 input: Option.some(name),
120 key: { name, ctrl: false, meta: false, shift: name !== name.toLowerCase() },
121});
122
123it.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
144it.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
161it.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";
33
44import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
55import * as Clock from "effect/Clock";
6import * as Cause from "effect/Cause";
67import * as Console from "effect/Console";
78import * as Context from "effect/Context";
89import * as Crypto from "effect/Crypto";
......@@ -12,8 +13,10 @@ import * as Effect from "effect/Effect";
1213import * as Encoding from "effect/Encoding";
1314import * as Layer from "effect/Layer";
1415import * as Option from "effect/Option";
16import * as Queue from "effect/Queue";
1517import * as Schema from "effect/Schema";
1618import * as Semaphore from "effect/Semaphore";
19import * as Terminal from "effect/Terminal";
1720import * as HttpClient from "effect/unstable/http/HttpClient";
1821import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
1922import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
......@@ -21,19 +24,105 @@ import * as HttpRouter from "effect/unstable/http/HttpRouter";
2124import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
2225import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
2326
27import {
28 buildConnectAuthorizeRequestUrl,
29 buildConnectClerkAuthorizeUrl,
30 checkConnectAuthCode,
31 connectCallbackUrl,
32} from "@t3tools/shared/connectAuth";
33
2434import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
25import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts";
35import * as ExternalLauncher from "../process/externalLauncher.ts";
36import {
37 cloudCliOAuthConfig,
38 hostedAppUrlConfig,
39 type CloudCliOAuthConfig,
40} from "./publicConfig.ts";
41import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
2642
2743const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";
2844const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);
2945const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));
46const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;
47
48export 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
58export type LoopbackAuthorizationResult =
59 | { readonly _tag: "AuthorizationCode"; readonly code: string }
60 | { readonly _tag: "HeadlessRequested" };
61
62const 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
77export 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
31119const PersistedToken = Schema.Struct({
32120 accessToken: Schema.String,
33121 refreshToken: Schema.String,
34122 expiresAtEpochMs: Schema.Number,
123 identity: Schema.optional(Schema.String),
35124});
36type PersistedToken = typeof PersistedToken.Type;
125export type PersistedToken = typeof PersistedToken.Type;
37126
38127const PersistedTokenJson = Schema.fromJsonString(PersistedToken);
39128const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson);
......@@ -42,10 +131,39 @@ const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson);
42131const 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
139const 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);
146const 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 */
153function 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
49167export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass<CloudCliCredentialRemovalError>()(
50168 "CloudCliCredentialRemovalError",
51169 { cause: Schema.Defect() },
......@@ -103,18 +221,18 @@ export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type;
103221export 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
113const 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
118236function stringToBytes(value: string): Uint8Array {
119237 return new TextEncoder().encode(value);
120238}
......@@ -123,10 +241,101 @@ function bytesToString(value: Uint8Array): string {
123241 return new TextDecoder().decode(value);
124242}
125243
244const 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
267const 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
277export 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 */
289export 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
126327export 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* () {
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* () {
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* () {
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* () {
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* () {
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
276491export const layer = Layer.effect(CloudCliTokenManager, make);
apps/server/src/cloud/bootService.test.ts created+529
......@@ -0,0 +1,529 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
10
11import {
12 HostProcessArguments,
13 HostProcessExecutablePath,
14 HostProcessPlatform,
15} from "@t3tools/shared/hostProcess";
16
17import * as ProcessRunner from "../processRunner.ts";
18import * as BootService from "./bootService.ts";
19
20const isUnsupportedError = Schema.is(BootService.BootServiceUnsupportedError);
21const isCommandError = Schema.is(BootService.BootServiceCommandError);
22
23interface RecordedCommand {
24 readonly command: string;
25 readonly args: ReadonlyArray<string>;
26}
27
28const 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
57const makeHost = (entry: string): BootService.BootServiceHost => ({
58 execPath: "/usr/local/bin/node",
59 cliEntryPath: entry,
60});
61
62const 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
70const 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
90it("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
124it("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
144it("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
172it.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 created+452
......@@ -0,0 +1,452 @@
1import * as Context from "effect/Context";
2import * as Config from "effect/Config";
3import * as DateTime from "effect/DateTime";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11
12import {
13 HostProcessArguments,
14 HostProcessExecutablePath,
15 HostProcessPlatform,
16} from "@t3tools/shared/hostProcess";
17
18import * 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
29const BOOT_SERVICE_NAME = "t3code";
30const BOOT_RUNTIME_DIR = "runtime";
31
32const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
33const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
34
35const 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 */
49export 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 */
58export 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 */
66export function quoteSystemdValue(value: string): string {
67 const escaped = escapeSystemdSpecifiers(value);
68 return /[\s"'\\]/.test(escaped)
69 ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
70 : escaped;
71}
72
73export 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 */
89export 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
118export 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
127export 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
144export 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
153export type BootServiceError =
154 | BootServiceUnsupportedError
155 | BootServiceCommandError
156 | BootServiceInstallError;
157
158export 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
167export 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
181export interface BootServiceHost {
182 readonly execPath: string;
183 readonly cliEntryPath: string;
184}
185
186export 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
447export 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 created+13
......@@ -0,0 +1,13 @@
1import { expect, it } from "@effect/vitest";
2
3import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
4
5it("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 created+140
......@@ -0,0 +1,140 @@
1export 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
......@@ -200,6 +200,7 @@ describe("reconcileDesiredCloudLink", () => {
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
......@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
44import * as Result from "effect/Result";
55
66import {
7 hostedAppUrlConfig,
78 makeCloudCliOAuthConfig,
89 makeRelayUrlConfig,
910 resolveRelayClientTracingConfig,
......@@ -47,6 +48,40 @@ it.effect("rejects an injected relay URL with a non-origin path", () =>
4748 makeRelayUrlConfig("https://embedded.example.test/path").pipe(provideEnv({}), Effect.flip),
4849);
4950
51it.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
68it.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
5085it.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 @@
1import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
12import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
23import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
34import * as Config from "effect/Config";
......@@ -15,7 +16,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefi
1516declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined;
1617
1718const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback";
18const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const;
19const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES;
1920
2021function validateRelayUrl(value: string) {
2122 const relayUrl = normalizeSecureRelayUrl(value);
......@@ -100,6 +101,44 @@ export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) {
100101
101102export 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 */
109export const hostedAppUrlConfig = makePublicValueConfig(
110 "T3CODE_HOSTED_APP_URL",
111 DEFAULT_HOSTED_APP_URL,
112).pipe(Config.mapOrFail(validateHostedAppUrl));
113
114function 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
103142function 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 {
123123export 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(
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 created+69
......@@ -0,0 +1,69 @@
1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 buildConnectCliClerkAuthorizeUrl,
5 hasConnectCliAuthConfig,
6 readConnectCliCallbackResult,
7} from "./connectCliAuth";
8
9// Any pk_test_* key decodes to <base64 hostname>.clerk.accounts.dev.
10const TEST_PUBLISHABLE_KEY = `pk_test_${btoa("witty-mole-42.clerk.accounts.dev$")}`;
11
12describe("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 created+91
......@@ -0,0 +1,91 @@
1import {
2 buildConnectClerkAuthorizeUrl,
3 connectCallbackUrl,
4 CONNECT_OAUTH_SCOPES,
5 type ConnectAuthorizeRequest,
6} from "@t3tools/shared/connectAuth";
7import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
8
9import { configuredHostedAppUrl, isHostedStaticApp } from "../hostedPairing";
10import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./publicConfig";
11
12const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state";
13
14export function resolveConnectCliOAuthClientId(): string | null {
15 return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined);
16}
17
18export 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 */
29export 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 */
38export 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
54export 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 */
69export 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
77export interface ConnectCliCallbackResult {
78 readonly code: string;
79 readonly state: string;
80}
81
82export 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 {
2424 };
2525}
2626
27function trimNonEmpty(value: string | undefined): string | null {
27export 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
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
46export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) {
47 return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;
48}
49
4650const NIGHTLY_STARS: ReadonlyArray<{
4751 cx: number;
4852 cy: number;
apps/web/src/components/auth/AuthSurfaceShell.tsx created+44
......@@ -0,0 +1,44 @@
1import type { ReactNode } from "react";
2
3import { APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../../branding";
4import { 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 */
10export 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 created+188
......@@ -0,0 +1,188 @@
1import { useAuth, useClerk, useUser } from "@clerk/react";
2import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
3import { useEffect, useRef, useState } from "react";
4
5import {
6 buildConnectCliClerkAuthorizeUrl,
7 readConnectCliAuthState,
8 readConnectCliCallbackResult,
9 rememberConnectCliAuthState,
10} from "../../cloud/connectCliAuth";
11import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
12import { AuthSurfaceShell } from "../auth/AuthSurfaceShell";
13import { Button } from "../ui/button";
14
15function 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
37const 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 */
48export 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 */
112export 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 @@
1import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl";
1import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
22
3const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes";
3import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "./pairingUrl";
44
55export interface HostedPairingRequest {
66 readonly host: string;
apps/web/src/routeTree.gen.ts+42
......@@ -11,6 +11,7 @@
1111import { Route as rootRouteImport } from './routes/__root'
1212import { Route as SettingsRouteImport } from './routes/settings'
1313import { Route as PairRouteImport } from './routes/pair'
14import { Route as ConnectRouteImport } from './routes/connect'
1415import { Route as ChatRouteImport } from './routes/_chat'
1516import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1617import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
......@@ -20,6 +21,7 @@ import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
2021import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
2122import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
2223import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
24import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
2325import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
2426import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
2527
......@@ -33,6 +35,11 @@ const PairRoute = PairRouteImport.update({
3335 path: '/pair',
3436 getParentRoute: () => rootRouteImport,
3537} as any)
38const ConnectRoute = ConnectRouteImport.update({
39 id: '/connect',
40 path: '/connect',
41 getParentRoute: () => rootRouteImport,
42} as any)
3643const ChatRoute = ChatRouteImport.update({
3744 id: '/_chat',
3845 getParentRoute: () => rootRouteImport,
......@@ -77,6 +84,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
7784 path: '/archived',
7885 getParentRoute: () => SettingsRoute,
7986} as any)
87const ConnectCallbackRoute = ConnectCallbackRouteImport.update({
88 id: '/connect_/callback',
89 path: '/connect/callback',
90 getParentRoute: () => rootRouteImport,
91} as any)
8092const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({
8193 id: '/draft/$draftId',
8294 path: '/draft/$draftId',
......@@ -91,8 +103,10 @@ const ChatEnvironmentIdThreadIdRoute =
91103
92104export 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 {
104118 '/draft/$draftId': typeof ChatDraftDraftIdRoute
105119}
106120export 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 {
120136export 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 {
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 {
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 {
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 {
181205}
182206export interface RootRouteChildren {
183207 ChatRoute: typeof ChatRouteWithChildren
208 ConnectRoute: typeof ConnectRoute
184209 PairRoute: typeof PairRoute
185210 SettingsRoute: typeof SettingsRouteWithChildren
211 ConnectCallbackRoute: typeof ConnectCallbackRoute
186212}
187213
188214declare module '@tanstack/react-router' {
......@@ -201,6 +227,13 @@ declare module '@tanstack/react-router' {
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' {
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(
321361
322362const rootRouteChildren: RootRouteChildren = {
323363 ChatRoute: ChatRouteWithChildren,
364 ConnectRoute: ConnectRoute,
324365 PairRoute: PairRoute,
325366 SettingsRoute: SettingsRouteWithChildren,
367 ConnectCallbackRoute: ConnectCallbackRoute,
326368}
327369export const routeTree = rootRouteImport
328370 ._addFileChildren(rootRouteChildren)
apps/web/src/routes/__root.tsx+1-1
......@@ -97,7 +97,7 @@ function RootRouteView() {
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 created+13
......@@ -0,0 +1,13 @@
1import { createFileRoute, redirect } from "@tanstack/react-router";
2
3import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth";
4import { ConnectCliAuthorizeSurface } from "../components/cloud/ConnectCliAuthSurface";
5
6export 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 created+13
......@@ -0,0 +1,13 @@
1import { createFileRoute, redirect } from "@tanstack/react-router";
2
3import { connectCliAuthRoutesEnabled } from "../cloud/connectCliAuth";
4import { ConnectCliCallbackSurface } from "../components/cloud/ConnectCliAuthSurface";
5
6export 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
......@@ -9,6 +9,7 @@ interface ImportMetaEnv {
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
......@@ -18,6 +18,7 @@ const configuredWsUrl = process.env.VITE_WS_URL?.trim();
1818const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
1919const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
2020const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
21const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || "";
2122const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || "";
2223const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || "";
2324const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || "";
......@@ -121,6 +122,9 @@ export default defineConfig(() => {
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
......@@ -135,6 +135,10 @@
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 created+78
......@@ -0,0 +1,78 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 buildConnectAuthorizeRequestUrl,
5 buildConnectClerkAuthorizeUrl,
6 connectCallbackUrl,
7 encodeConnectAuthCode,
8 parseConnectAuthCode,
9 readConnectAuthorizeRequest,
10} from "./connectAuth.ts";
11
12describe("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 created+124
......@@ -0,0 +1,124 @@
1import { readHashParams } from "./remote.ts";
2
3const CONNECT_AUTH_STATE_PARAM = "state";
4const CONNECT_AUTH_CHALLENGE_PARAM = "challenge";
5const CONNECT_AUTH_CODE_SEPARATOR = ".";
6
7const CONNECT_AUTHORIZE_PATH = "/connect";
8const 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 */
15export 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 */
21export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email"] as const;
22
23export 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 */
33export 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
46export 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
56export function connectCallbackUrl(hostedAppUrl: string): string {
57 return new URL(CONNECT_CALLBACK_PATH, hostedAppUrl).toString();
58}
59
60export 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
79export 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 */
91export 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 */
101export 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
115export 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
......@@ -30,4 +30,18 @@ export const HostProcessEnvironment = Context.Reference<NodeJS.ProcessEnv>(
3030 },
3131);
3232
33export const HostProcessExecutablePath = Context.Reference<string>(
34 "@t3tools/shared/hostProcess/HostProcessExecutablePath",
35 {
36 defaultValue: () => process.execPath,
37 },
38);
39
40export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(
41 "@t3tools/shared/hostProcess/HostProcessArguments",
42 {
43 defaultValue: () => process.argv,
44 },
45);
46
3347export 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";
55const HOSTED_PAIRING_LABEL_PARAM = "label";
66const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]);
77
8const readHashParams = (url: URL): URLSearchParams =>
8export const readHashParams = (url: URL): URLSearchParams =>
99 new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
1010
1111export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass<RemoteBackendUrlMissingError>()(
scripts/lib/public-config.ts+6-1
......@@ -55,6 +55,7 @@ export function loadRepoEnv({
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
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,