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