Unified · Split
| @@ -18,6 +18,10 @@ · 17 unmodified lines | ||
| 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 |
| @@ -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 — every step reuses code that already exists. One PR, built as four phases with clear commit boundaries — 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 | + <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 — 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 — <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 — <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 — <strong>untouched</strong></li> | |
| 114 | + <li>Existing subcommands <code>login/link/status/unlink/logout</code> — 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 — t3 CLI</text> | |
| 128 | + <text x="410" y="43" fill="#fff" font-size="14" font-weight="600" text-anchor="middle">Laptop — 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#{state,challenge}</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 → /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&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 {code + verifier} → 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 → Connected!</text> | |
| 171 | +</svg> | |
| 172 | +<figcaption>The verifier never leaves the box (steps 1→7), so the authorization code is worthless if observed. state/challenge ride the URL fragment — 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 — 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@…") 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 — the same timeout the existing loopback flow already uses. Wrong/expired code → 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–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 — 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 — 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> — nothing else); <code>--headless</code> flag as manual override. Loopback stays the default on desktop — 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 — Linux first</strong> (the SSH case). One new module: pinned runtime install to <code>~/.t3/runtime/versions/<v></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 — systemd user units fail invisibly otherwise. Unit-file generation is pure string-building → 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 < latest-on-channel</code> using the existing version-skew surface + hosted manifest; one authenticated "update" command — client says <em>update</em>, daemon resolves/verifies the version itself (never client-specified — that would be RCE). Stage install → verify → atomic symlink swap → <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/ ← npm install --prefix (gets native deps right: node-pty etc.) | |
| 220 | +└── current -> versions/0.0.27 | |
| 221 | + | |
| 222 | +~/.config/systemd/user/t3code.service ← ExecStart=/abs/path/node .../current/.../t3 serve | |
| 223 | +loginctl enable-linger $USER ← 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">✗</span> Relay / <code>infra</code> / contracts changes — none, in any phase</li> | |
| 229 | + <li><span class="no">✗</span> Short-link service (<code>app.t3.codes/c/AB7K</code>) — only matters for hand-typing; revisit if ever needed</li> | |
| 230 | + <li><span class="no">✗</span> RFC 8628 device grant — wrong UX direction, unverified Clerk support</li> | |
| 231 | + <li><span class="no">✗</span> Auto-update loop in the daemon — web-triggered only (phase 4 follow-up), user stays in control</li> | |
| 232 | + <li><span class="no">✗</span> Project auto-registration — workspace assumed set up; the web UI handles the rest</li> | |
| 233 | + <li><span class="no">✗</span> Changing existing loopback flow, subcommands, or desktop behavior</li> | |
| 234 | +</ul> | |
| 235 | + | |
| 236 | +<h2>Risks & checks</h2> | |
| 237 | +<ul> | |
| 238 | + <li><span class="warn">⚠</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">⚠</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 — hence the phase 3 requirement to log to a printed file path.</li> | |
| 240 | + <li><span class="warn">⚠</span> <strong>Linger prompt honesty:</strong> the y/n prompt should say the machine becomes reachable via T3 Connect whenever powered on — that's the feature, but say it.</li> | |
| 241 | + <li><span class="ok">✓</span> Re-running <code>connect</code> when linked → 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) — 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> | |
| @@ -236,7 +236,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { · 235 unmodified lines | ||
| 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) => { · 11 unmodified lines | ||
| 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) => { · 4 unmodified lines | ||
| 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 | }), |
| @@ -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 = { · 1 unmodified line | ||
| 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; |
| @@ -6,9 +6,12 @@ import { · 5 unmodified lines | ||
| 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"; · 1 unmodified line | ||
| 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 { · 3 unmodified lines | ||
| 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"; · 5 unmodified lines | ||
| 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( · 2 unmodified lines | ||
| 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: { · 261 unmodified lines | ||
| 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: { · 7 unmodified lines | ||
| 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>( · 2 unmodified lines | ||
| 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>( · 3 unmodified lines | ||
| 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", { · 1 unmodified line | ||
| 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", { · 6 unmodified lines | ||
| 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", { · 131 unmodified lines | ||
| 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, |
| @@ -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 | +}); | |
| @@ -3,6 +3,7 @@ import * as NodeHttp from "node:http"; · 2 unmodified lines | ||
| 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"; · 3 unmodified lines | ||
| 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"; · 1 unmodified line | ||
| 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); · 2 unmodified lines | ||
| 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; · 51 unmodified lines | ||
| 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 { · 2 unmodified lines | ||
| 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* () { · 3 unmodified lines | ||
| 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* () { · 1 unmodified line | ||
| 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* () { · 8 unmodified lines | ||
| 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* () { · 9 unmodified lines | ||
| 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* () { · 7 unmodified lines | ||
| 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); |
| @@ -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 | +}); | |
| @@ -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)); | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -200,6 +200,7 @@ describe("reconcileDesiredCloudLink", () => { · 199 unmodified lines | ||
| 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 | ), |
| @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines | ||
| 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", () => · 37 unmodified lines | ||
| 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({ |
| @@ -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 · 11 unmodified lines | ||
| 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) { · 78 unmodified lines | ||
| 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( |
| @@ -123,15 +123,11 @@ export interface RunMigrationsOptions { · 122 unmodified lines | ||
| 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 | |
| @@ -322,7 +322,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 321 unmodified lines | ||
| 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 | ), |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -24,7 +24,7 @@ export interface CloudPublicConfig { · 23 unmodified lines | ||
| 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 | |
| @@ -38,11 +38,15 @@ export function SidebarStageBackdrop({ variant }: { variant: SidebarStageBackdro · 37 unmodified lines | ||
| 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; |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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; |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | ||
| 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' · 3 unmodified lines | ||
| 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({ · 7 unmodified lines | ||
| 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({ · 38 unmodified lines | ||
| 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 = · 8 unmodified lines | ||
| 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 { · 5 unmodified lines | ||
| 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 { · 8 unmodified lines | ||
| 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 { · 9 unmodified lines | ||
| 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 { · 5 unmodified lines | ||
| 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 { · 7 unmodified lines | ||
| 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 { · 8 unmodified lines | ||
| 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' { · 12 unmodified lines | ||
| 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' { · 57 unmodified lines | ||
| 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( · 51 unmodified lines | ||
| 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) |
| @@ -97,7 +97,7 @@ function RootRouteView() { · 96 unmodified lines | ||
| 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 /> |
| @@ -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 | +}); | |
| @@ -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 | +}); | |
| @@ -9,6 +9,7 @@ interface ImportMetaEnv { · 8 unmodified lines | ||
| 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; |
| @@ -18,6 +18,7 @@ const configuredWsUrl = process.env.VITE_WS_URL?.trim(); · 17 unmodified lines | ||
| 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(() => { · 97 unmodified lines | ||
| 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, |
| @@ -135,6 +135,10 @@ · 134 unmodified lines | ||
| 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" |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -30,4 +30,18 @@ export const HostProcessEnvironment = Context.Reference<NodeJS.ProcessEnv>( · 29 unmodified lines | ||
| 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"); |
| @@ -5,7 +5,7 @@ const HOSTED_PAIRING_HOST_PARAM = "host"; · 4 unmodified lines | ||
| 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>()( |
| @@ -55,6 +55,7 @@ export function loadRepoEnv({ · 54 unmodified lines | ||
| 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 · 55 unmodified lines | ||
| 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, |