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