| author | |
| committer | |
| log | b7559c467803f923e59a287c112b4c92b96e15c7 |
| tree | e30c3661bb786525321d701fc1b601b6e5450742 |
| parent | b96308fc5adba6eecae5ee26efcfc8a01caaafd2 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>159 files changed, 20030 insertions(+), 4271 deletions(-)
.docs/remote-architecture.md created+302| ... | ... | @@ -0,0 +1,302 @@ |
| 1 | # Remote Architecture | |
| 2 | ||
| 3 | This document describes the target architecture for first-class remote environments in T3 Code. | |
| 4 | ||
| 5 | It is intentionally architecture-first. It does not define a complete implementation plan or user-facing rollout checklist. The goal is to establish the core model so remote support can be added without another broad rewrite. | |
| 6 | ||
| 7 | ## Goals | |
| 8 | ||
| 9 | - Treat remote environments as first-class product primitives, not special cases. | |
| 10 | - Support multiple ways to reach the same environment. | |
| 11 | - Keep the T3 server as the execution boundary. | |
| 12 | - Let desktop, mobile, and web all share the same conceptual model. | |
| 13 | - Avoid introducing a local control plane unless product pressure proves it is necessary. | |
| 14 | ||
| 15 | ## Non-goals | |
| 16 | ||
| 17 | - Replacing the existing WebSocket server boundary with a custom transport protocol. | |
| 18 | - Making SSH the only remote story. | |
| 19 | - Syncing provider auth across machines. | |
| 20 | - Shipping every access method in the first iteration. | |
| 21 | ||
| 22 | ## High-level architecture | |
| 23 | ||
| 24 | T3 already has a clean runtime boundary: the client talks to a T3 server over HTTP/WebSocket, and the server owns orchestration, providers, terminals, git, and filesystem operations. | |
| 25 | ||
| 26 | Remote support should preserve that boundary. | |
| 27 | ||
| 28 | ```text | |
| 29 | ┌──────────────────────────────────────────────┐ | |
| 30 | │ Client (desktop / mobile / web) │ | |
| 31 | │ │ | |
| 32 | │ - known environments │ | |
| 33 | │ - connection manager │ | |
| 34 | │ - environment-aware routing │ | |
| 35 | └───────────────┬──────────────────────────────┘ | |
| 36 | │ | |
| 37 | │ resolves one access endpoint | |
| 38 | │ | |
| 39 | ┌───────────────▼──────────────────────────────┐ | |
| 40 | │ Access method │ | |
| 41 | │ │ | |
| 42 | │ - direct ws / wss │ | |
| 43 | │ - tunneled ws / wss │ | |
| 44 | │ - desktop-managed ssh bootstrap + forward │ | |
| 45 | └───────────────┬──────────────────────────────┘ | |
| 46 | │ | |
| 47 | │ connects to one T3 server | |
| 48 | │ | |
| 49 | ┌───────────────▼──────────────────────────────┐ | |
| 50 | │ Execution environment = one T3 server │ | |
| 51 | │ │ | |
| 52 | │ - environment identity │ | |
| 53 | │ - provider state │ | |
| 54 | │ - projects / threads / terminals │ | |
| 55 | │ - git / filesystem / process runtime │ | |
| 56 | └──────────────────────────────────────────────┘ | |
| 57 | ``` | |
| 58 | ||
| 59 | The important decision is that remoteness is expressed at the environment connection layer, not by splitting the T3 runtime itself. | |
| 60 | ||
| 61 | ## Domain model | |
| 62 | ||
| 63 | ### ExecutionEnvironment | |
| 64 | ||
| 65 | An `ExecutionEnvironment` is one running T3 server instance. | |
| 66 | ||
| 67 | It is the unit that owns: | |
| 68 | ||
| 69 | - provider availability and auth state | |
| 70 | - model availability | |
| 71 | - projects and threads | |
| 72 | - terminal processes | |
| 73 | - filesystem access | |
| 74 | - git operations | |
| 75 | - server settings | |
| 76 | ||
| 77 | It is identified by a stable `environmentId`. | |
| 78 | ||
| 79 | This is the shared cross-client primitive. Desktop, mobile, and web should all reason about the same concept here. | |
| 80 | ||
| 81 | ### KnownEnvironment | |
| 82 | ||
| 83 | A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach. | |
| 84 | ||
| 85 | It is not server-authored. It is local to a device or client profile. | |
| 86 | ||
| 87 | Examples: | |
| 88 | ||
| 89 | - a saved LAN URL | |
| 90 | - a saved public `wss://` endpoint | |
| 91 | - a desktop-managed SSH host entry | |
| 92 | - a saved tunneled environment | |
| 93 | ||
| 94 | A known environment may or may not know the target `environmentId` before first successful connect. | |
| 95 | ||
| 96 | ### AccessEndpoint | |
| 97 | ||
| 98 | An `AccessEndpoint` is one concrete way to reach a known environment. | |
| 99 | ||
| 100 | This is the key abstraction that keeps SSH from taking over the model. | |
| 101 | ||
| 102 | A single environment may have many endpoints: | |
| 103 | ||
| 104 | - `wss://t3.example.com` | |
| 105 | - `ws://10.0.0.25:3773` | |
| 106 | - a tunneled relay URL | |
| 107 | - a desktop-managed SSH tunnel that resolves to a local forwarded WebSocket URL | |
| 108 | ||
| 109 | The environment stays the same. Only the access path changes. | |
| 110 | ||
| 111 | ### RepositoryIdentity | |
| 112 | ||
| 113 | `RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. | |
| 114 | ||
| 115 | It is not used for routing. It is only used for UI grouping and correlation between local and remote clones of the same repository. | |
| 116 | ||
| 117 | ### Workspace / Project | |
| 118 | ||
| 119 | The current `Project` model remains environment-local. | |
| 120 | ||
| 121 | That means: | |
| 122 | ||
| 123 | - a local clone and a remote clone are different projects | |
| 124 | - they may share a `RepositoryIdentity` | |
| 125 | - threads still bind to one project in one environment | |
| 126 | ||
| 127 | ## Access methods | |
| 128 | ||
| 129 | Access methods answer one question: | |
| 130 | ||
| 131 | How does the client speak WebSocket to a T3 server? | |
| 132 | ||
| 133 | They do not answer: | |
| 134 | ||
| 135 | - how the server got started | |
| 136 | - who manages the server process | |
| 137 | - whether the environment is local or remote | |
| 138 | ||
| 139 | ### 1. Direct WebSocket access | |
| 140 | ||
| 141 | Examples: | |
| 142 | ||
| 143 | - `ws://10.0.0.15:3773` | |
| 144 | - `wss://t3.example.com` | |
| 145 | ||
| 146 | This is the base model and should be the first-class default. | |
| 147 | ||
| 148 | Benefits: | |
| 149 | ||
| 150 | - works for desktop, mobile, and web | |
| 151 | - no client-specific process management required | |
| 152 | - best fit for hosted or self-managed remote T3 deployments | |
| 153 | ||
| 154 | ### 2. Tunneled WebSocket access | |
| 155 | ||
| 156 | Examples: | |
| 157 | ||
| 158 | - public relay URLs | |
| 159 | - private network relay URLs | |
| 160 | - local tunnel products such as pipenet | |
| 161 | ||
| 162 | This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay. | |
| 163 | ||
| 164 | For T3, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment. | |
| 165 | ||
| 166 | This is especially useful when: | |
| 167 | ||
| 168 | - the host is behind NAT | |
| 169 | - inbound ports are unavailable | |
| 170 | - mobile must reach a desktop-hosted environment | |
| 171 | - a machine should be reachable without exposing raw LAN or public ports | |
| 172 | ||
| 173 | ### 3. Desktop-managed SSH access | |
| 174 | ||
| 175 | SSH is an access and launch helper, not a separate environment type. | |
| 176 | ||
| 177 | The desktop main process can use SSH to: | |
| 178 | ||
| 179 | - reach a machine | |
| 180 | - probe it | |
| 181 | - launch or reuse a remote T3 server | |
| 182 | - establish a local port forward | |
| 183 | ||
| 184 | After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port. | |
| 185 | ||
| 186 | This keeps the renderer transport model consistent with every other access method. | |
| 187 | ||
| 188 | ## Launch methods | |
| 189 | ||
| 190 | Launch methods answer a different question: | |
| 191 | ||
| 192 | How does a T3 server come to exist on the target machine? | |
| 193 | ||
| 194 | Launch and access should stay separate in the design. | |
| 195 | ||
| 196 | ### 1. Pre-existing server | |
| 197 | ||
| 198 | The simplest launch method is no launch at all. | |
| 199 | ||
| 200 | The user or operator already runs T3 on the target machine, and the client connects through a direct or tunneled WebSocket endpoint. | |
| 201 | ||
| 202 | This should be the first remote mode shipped because it validates the environment model with minimal extra machinery. | |
| 203 | ||
| 204 | ### 2. Desktop-managed remote launch over SSH | |
| 205 | ||
| 206 | This is the main place where Zed is a useful reference. | |
| 207 | ||
| 208 | Useful ideas to borrow from Zed: | |
| 209 | ||
| 210 | - remote probing | |
| 211 | - platform detection | |
| 212 | - session directories with pid/log metadata | |
| 213 | - reconnect-friendly launcher behavior | |
| 214 | - desktop-owned connection UX | |
| 215 | ||
| 216 | What should be different in T3: | |
| 217 | ||
| 218 | - no custom stdio/socket proxy protocol between renderer and remote runtime | |
| 219 | - no attempt to make the remote runtime look like an editor transport | |
| 220 | - keep the final client-to-server connection as WebSocket | |
| 221 | ||
| 222 | The recommended T3 flow is: | |
| 223 | ||
| 224 | 1. Desktop connects over SSH. | |
| 225 | 2. Desktop probes the remote machine and verifies T3 availability. | |
| 226 | 3. Desktop launches or reuses a remote T3 server. | |
| 227 | 4. Desktop establishes local port forwarding. | |
| 228 | 5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. | |
| 229 | ||
| 230 | ### 3. Client-managed local publish | |
| 231 | ||
| 232 | This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel. | |
| 233 | ||
| 234 | This is useful for: | |
| 235 | ||
| 236 | - exposing a desktop-hosted environment to mobile | |
| 237 | - temporary remote access without changing router or firewall settings | |
| 238 | ||
| 239 | This is still a launch concern, not a new environment kind. | |
| 240 | ||
| 241 | ## Why access and launch must stay separate | |
| 242 | ||
| 243 | These concerns are easy to conflate, but separating them prevents architectural drift. | |
| 244 | ||
| 245 | Examples: | |
| 246 | ||
| 247 | - A manually hosted T3 server might be reached through direct `wss`. | |
| 248 | - The same server might also be reachable through a tunnel. | |
| 249 | - An SSH-managed server might be launched over SSH but then reached through forwarded WebSocket. | |
| 250 | - A local desktop server might be published through a tunnel for mobile. | |
| 251 | ||
| 252 | In all of those cases, the `ExecutionEnvironment` is the same kind of thing. | |
| 253 | ||
| 254 | Only the launch and access paths differ. | |
| 255 | ||
| 256 | ## Security model | |
| 257 | ||
| 258 | Remote support must assume that some environments will be reachable over untrusted networks. | |
| 259 | ||
| 260 | That means: | |
| 261 | ||
| 262 | - remote-capable environments should require explicit authentication | |
| 263 | - tunnel exposure should not rely on obscurity | |
| 264 | - client-saved endpoints should carry enough auth metadata to reconnect safely | |
| 265 | ||
| 266 | T3 already supports a WebSocket auth token on the server. That should become a first-class part of environment access rather than remaining an incidental query parameter convention. | |
| 267 | ||
| 268 | For publicly reachable environments, authenticated access should be treated as required. | |
| 269 | ||
| 270 | ## Relationship to Zed | |
| 271 | ||
| 272 | Zed is a useful reference implementation for managed remote launch and reconnect behavior. | |
| 273 | ||
| 274 | The relevant lessons are: | |
| 275 | ||
| 276 | - remote bootstrap should be explicit | |
| 277 | - reconnect should be first-class | |
| 278 | - connection UX belongs in the client shell | |
| 279 | - runtime ownership should stay clearly on the remote host | |
| 280 | ||
| 281 | The important mismatch is transport shape. | |
| 282 | ||
| 283 | Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime. | |
| 284 | ||
| 285 | T3 should not copy that part. | |
| 286 | ||
| 287 | T3 already has the right runtime boundary: | |
| 288 | ||
| 289 | - one T3 server per environment | |
| 290 | - ordinary HTTP/WebSocket between client and environment | |
| 291 | ||
| 292 | So T3 should borrow Zed's launch discipline, not its transport protocol. | |
| 293 | ||
| 294 | ## Recommended rollout | |
| 295 | ||
| 296 | 1. First-class known environments and access endpoints. | |
| 297 | 2. Direct `ws` / `wss` remote environments. | |
| 298 | 3. Authenticated tunnel-backed environments. | |
| 299 | 4. Desktop-managed SSH launch and forwarding. | |
| 300 | 5. Multi-environment UI improvements after the base runtime path is proven. | |
| 301 | ||
| 302 | This ordering keeps the architecture network-first and transport-agnostic while still leaving room for richer managed remote flows. |
.github/workflows/ci.yml+1-1| ... | ... | @@ -72,7 +72,7 @@ jobs: |
| 72 | 72 | - name: Verify preload bundle output |
| 73 | 73 | run: | |
| 74 | 74 | test -f apps/desktop/dist-electron/preload.js |
| 75 | grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js | |
| 75 | grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js | |
| 76 | 76 | |
| 77 | 77 | release_smoke: |
| 78 | 78 | name: Release Smoke |
.oxfmtrc.json+2-1| ... | ... | @@ -9,7 +9,8 @@ |
| 9 | 9 | "bun.lock", |
| 10 | 10 | "*.tsbuildinfo", |
| 11 | 11 | "**/routeTree.gen.ts", |
| 12 | "apps/web/public/mockServiceWorker.js" | |
| 12 | "apps/web/public/mockServiceWorker.js", | |
| 13 | "apps/web/src/lib/vendor/qrcodegen.ts" | |
| 13 | 14 | ], |
| 14 | 15 | "sortPackageJson": {} |
| 15 | 16 | } |
.plans/18-server-auth-model.md created+823| ... | ... | @@ -0,0 +1,823 @@ |
| 1 | # Server Auth Model Plan | |
| 2 | ||
| 3 | ## Purpose | |
| 4 | ||
| 5 | Define the long-term server auth architecture for T3 Code before first-class remote environments ship. | |
| 6 | ||
| 7 | This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods. | |
| 8 | ||
| 9 | This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code. | |
| 10 | ||
| 11 | ## Primary goals | |
| 12 | ||
| 13 | - Make auth server-wide, not WebSocket-only. | |
| 14 | - Make insecure exposure hard to do accidentally. | |
| 15 | - Preserve zero-login local desktop UX for desktop-managed environments. | |
| 16 | - Support browser-native pairing and session auth. | |
| 17 | - Leave room for native/mobile credentials later without rewriting the server boundary. | |
| 18 | - Keep auth separate from transport and launch method. | |
| 19 | ||
| 20 | ## Non-goals | |
| 21 | ||
| 22 | - Full multi-user authorization and RBAC. | |
| 23 | - OAuth / SSO / enterprise identity. | |
| 24 | - Passkeys or biometric UX in v1. | |
| 25 | - Syncing auth state across environments. | |
| 26 | - Designing the full remote environment product in this document. | |
| 27 | ||
| 28 | ## Core decisions | |
| 29 | ||
| 30 | ### 1. Auth is a server concern | |
| 31 | ||
| 32 | Every privileged surface of the T3 server must go through the same auth policy engine: | |
| 33 | ||
| 34 | - HTTP routes | |
| 35 | - WebSocket upgrades | |
| 36 | - RPC methods reached through WebSocket | |
| 37 | ||
| 38 | The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product. | |
| 39 | ||
| 40 | ### 2. Pairing and session are different things | |
| 41 | ||
| 42 | The system should distinguish: | |
| 43 | ||
| 44 | - bootstrap credentials | |
| 45 | - session credentials | |
| 46 | ||
| 47 | Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated. | |
| 48 | ||
| 49 | Session credentials are the durable credentials used after pairing. | |
| 50 | ||
| 51 | Bootstrap should never become the long-lived request credential. | |
| 52 | ||
| 53 | ### 3. Auth and transport are separate | |
| 54 | ||
| 55 | Auth must not be defined by how the client reached the server. | |
| 56 | ||
| 57 | Examples: | |
| 58 | ||
| 59 | - local desktop-managed server | |
| 60 | - LAN `ws://` | |
| 61 | - public `wss://` | |
| 62 | - tunneled `wss://` | |
| 63 | - SSH-forwarded `ws://127.0.0.1:<port>` | |
| 64 | ||
| 65 | All of these should feed into the same auth model. | |
| 66 | ||
| 67 | ### 4. Exposure level changes defaults | |
| 68 | ||
| 69 | The more exposed an environment is, the narrower the safe default should be. | |
| 70 | ||
| 71 | Safe default expectations: | |
| 72 | ||
| 73 | - local desktop-managed: auto-pair allowed | |
| 74 | - loopback browser access: explicit bootstrap allowed | |
| 75 | - non-loopback bind: auth required | |
| 76 | - tunnel/public endpoint: auth required, explicit enablement required | |
| 77 | ||
| 78 | ### 5. Browser and native clients may use different session credentials | |
| 79 | ||
| 80 | The auth model should support more than one session credential type even if only one ships first. | |
| 81 | ||
| 82 | Examples: | |
| 83 | ||
| 84 | - browser session cookie | |
| 85 | - native bearer/device token | |
| 86 | ||
| 87 | This should be represented in the model now, even if browser cookies are the first implementation. | |
| 88 | ||
| 89 | ## Target auth domain | |
| 90 | ||
| 91 | ### Route classes | |
| 92 | ||
| 93 | Every route or transport entrypoint should be classified as one of: | |
| 94 | ||
| 95 | 1. `public` | |
| 96 | 2. `bootstrap` | |
| 97 | 3. `authenticated` | |
| 98 | ||
| 99 | #### `public` | |
| 100 | ||
| 101 | Unauthenticated by definition. | |
| 102 | ||
| 103 | Should be extremely small. Examples: | |
| 104 | ||
| 105 | - static shell needed to render the pairing/login UI | |
| 106 | - favicon/assets required for the pairing screen | |
| 107 | - a minimal server health/version endpoint if needed | |
| 108 | ||
| 109 | #### `bootstrap` | |
| 110 | ||
| 111 | Used only to exchange a bootstrap credential for a session. | |
| 112 | ||
| 113 | Examples: | |
| 114 | ||
| 115 | - Initial bootstrap envelope over file descriptor at startup | |
| 116 | - `POST /api/auth/bootstrap` | |
| 117 | - `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX | |
| 118 | ||
| 119 | #### `authenticated` | |
| 120 | ||
| 121 | Everything that reveals machine state or mutates it. | |
| 122 | ||
| 123 | Examples: | |
| 124 | ||
| 125 | - WebSocket upgrade | |
| 126 | - orchestration snapshot and events | |
| 127 | - terminal open/write/close | |
| 128 | - project search and file writes | |
| 129 | - git routes | |
| 130 | - attachments | |
| 131 | - project favicon lookup | |
| 132 | - server settings | |
| 133 | ||
| 134 | The default stance should be: if it touches the machine, it is authenticated. | |
| 135 | ||
| 136 | ## Credential model | |
| 137 | ||
| 138 | ### Bootstrap credentials | |
| 139 | ||
| 140 | Initial credential types to model: | |
| 141 | ||
| 142 | - `desktop-bootstrap` | |
| 143 | - `one-time-token` | |
| 144 | ||
| 145 | Possible future credential types: | |
| 146 | ||
| 147 | - `device-code` | |
| 148 | - `passkey-assertion` | |
| 149 | - `external-identity` | |
| 150 | ||
| 151 | #### `desktop-bootstrap` | |
| 152 | ||
| 153 | Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments. | |
| 154 | ||
| 155 | Properties: | |
| 156 | ||
| 157 | - launcher-provided | |
| 158 | - short-lived | |
| 159 | - one-time or bounded-use | |
| 160 | - never shown to the user as a reusable password | |
| 161 | ||
| 162 | #### `one-time-token` | |
| 163 | ||
| 164 | Used for explicit browser/mobile pairing flows. | |
| 165 | ||
| 166 | Properties: | |
| 167 | ||
| 168 | - short TTL | |
| 169 | - one-time use | |
| 170 | - safe to embed in a pairing URL fragment | |
| 171 | - exchanged for a session credential | |
| 172 | ||
| 173 | ### Session credentials | |
| 174 | ||
| 175 | Initial credential types to model: | |
| 176 | ||
| 177 | - `browser-session-cookie` | |
| 178 | - `bearer-session-token` | |
| 179 | ||
| 180 | #### `browser-session-cookie` | |
| 181 | ||
| 182 | Primary browser credential. | |
| 183 | ||
| 184 | Properties: | |
| 185 | ||
| 186 | - signed | |
| 187 | - `HttpOnly` | |
| 188 | - bounded lifetime | |
| 189 | - revocable by server key rotation or session invalidation | |
| 190 | ||
| 191 | #### `bearer-session-token` | |
| 192 | ||
| 193 | Reserved for native/mobile or non-browser clients. | |
| 194 | ||
| 195 | Properties: | |
| 196 | ||
| 197 | - opaque token, not a bootstrap secret | |
| 198 | - long enough lifetime to survive reconnects | |
| 199 | - stored in secure client storage when available | |
| 200 | ||
| 201 | ## Auth policy model | |
| 202 | ||
| 203 | Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics. | |
| 204 | ||
| 205 | ### Policy examples | |
| 206 | ||
| 207 | #### `DesktopManagedLocalPolicy` | |
| 208 | ||
| 209 | Default for desktop-managed local server. | |
| 210 | ||
| 211 | Allowed bootstrap methods: | |
| 212 | ||
| 213 | - `desktop-bootstrap` | |
| 214 | ||
| 215 | Allowed session methods: | |
| 216 | ||
| 217 | - `browser-session-cookie` | |
| 218 | ||
| 219 | Disabled by default: | |
| 220 | ||
| 221 | - `one-time-token` | |
| 222 | - `bearer-session-token` | |
| 223 | - password login | |
| 224 | - public pairing | |
| 225 | ||
| 226 | #### `LoopbackBrowserPolicy` | |
| 227 | ||
| 228 | Used for browser access on localhost without desktop-managed bootstrap. | |
| 229 | ||
| 230 | Allowed bootstrap methods: | |
| 231 | ||
| 232 | - `one-time-token` | |
| 233 | ||
| 234 | Allowed session methods: | |
| 235 | ||
| 236 | - `browser-session-cookie` | |
| 237 | ||
| 238 | #### `RemoteReachablePolicy` | |
| 239 | ||
| 240 | Used when binding non-loopback or using an explicit remote/tunnel workflow. | |
| 241 | ||
| 242 | Allowed bootstrap methods: | |
| 243 | ||
| 244 | - `one-time-token` | |
| 245 | - possibly `desktop-bootstrap` when a desktop shell is brokering access | |
| 246 | ||
| 247 | Allowed session methods: | |
| 248 | ||
| 249 | - `browser-session-cookie` | |
| 250 | - `bearer-session-token` | |
| 251 | ||
| 252 | #### `UnsafeNoAuthPolicy` | |
| 253 | ||
| 254 | Should exist only as an explicit escape hatch. | |
| 255 | ||
| 256 | Requirements: | |
| 257 | ||
| 258 | - explicit opt-in flag | |
| 259 | - loud startup warnings | |
| 260 | - never defaulted automatically | |
| 261 | ||
| 262 | ## Effect-native service model | |
| 263 | ||
| 264 | ### `ServerAuth` | |
| 265 | ||
| 266 | The main auth facade used by HTTP routes and WebSocket upgrade handling. | |
| 267 | ||
| 268 | Responsibilities: | |
| 269 | ||
| 270 | - classify requests | |
| 271 | - authenticate requests | |
| 272 | - authorize bootstrap attempts | |
| 273 | - create sessions from bootstrap credentials | |
| 274 | - enforce policy by environment mode | |
| 275 | ||
| 276 | Sketch: | |
| 277 | ||
| 278 | ```ts | |
| 279 | export interface ServerAuthShape { | |
| 280 | readonly getCapabilities: Effect.Effect<AuthCapabilities>; | |
| 281 | readonly authenticateHttpRequest: ( | |
| 282 | request: HttpServerRequest.HttpServerRequest, | |
| 283 | routeClass: RouteAuthClass, | |
| 284 | ) => Effect.Effect<AuthContext, AuthError>; | |
| 285 | readonly authenticateWebSocketUpgrade: ( | |
| 286 | request: HttpServerRequest.HttpServerRequest, | |
| 287 | ) => Effect.Effect<AuthContext, AuthError>; | |
| 288 | readonly exchangeBootstrapCredential: ( | |
| 289 | input: BootstrapExchangeInput, | |
| 290 | ) => Effect.Effect<SessionGrant, AuthError>; | |
| 291 | } | |
| 292 | ||
| 293 | export class ServerAuth extends ServiceMap.Service<ServerAuth, ServerAuthShape>()( | |
| 294 | "t3/ServerAuth", | |
| 295 | ) {} | |
| 296 | ``` | |
| 297 | ||
| 298 | ### `BootstrapCredentialService` | |
| 299 | ||
| 300 | Owns issuance, storage, validation, and consumption of bootstrap credentials. | |
| 301 | ||
| 302 | Responsibilities: | |
| 303 | ||
| 304 | - issue desktop bootstrap grants | |
| 305 | - issue one-time pairing tokens | |
| 306 | - validate TTL and single-use semantics | |
| 307 | - consume bootstrap grants atomically | |
| 308 | ||
| 309 | Sketch: | |
| 310 | ||
| 311 | ```ts | |
| 312 | export interface BootstrapCredentialServiceShape { | |
| 313 | readonly issueDesktopBootstrap: ( | |
| 314 | input: IssueDesktopBootstrapInput, | |
| 315 | ) => Effect.Effect<BootstrapCredential>; | |
| 316 | readonly issueOneTimeToken: ( | |
| 317 | input: IssueOneTimeTokenInput, | |
| 318 | ) => Effect.Effect<BootstrapCredential>; | |
| 319 | readonly consume: ( | |
| 320 | presented: PresentedBootstrapCredential, | |
| 321 | ) => Effect.Effect<ConsumedBootstrapCredential, BootstrapCredentialError>; | |
| 322 | } | |
| 323 | ``` | |
| 324 | ||
| 325 | ### `SessionCredentialService` | |
| 326 | ||
| 327 | Owns creation and validation of authenticated sessions. | |
| 328 | ||
| 329 | Responsibilities: | |
| 330 | ||
| 331 | - mint cookie sessions | |
| 332 | - mint bearer sessions | |
| 333 | - validate active session credentials | |
| 334 | - revoke sessions if needed later | |
| 335 | ||
| 336 | Sketch: | |
| 337 | ||
| 338 | ```ts | |
| 339 | export interface SessionCredentialServiceShape { | |
| 340 | readonly createBrowserSession: ( | |
| 341 | input: CreateSessionFromBootstrapInput, | |
| 342 | ) => Effect.Effect<BrowserSessionGrant, SessionCredentialError>; | |
| 343 | readonly createBearerSession: ( | |
| 344 | input: CreateSessionFromBootstrapInput, | |
| 345 | ) => Effect.Effect<BearerSessionGrant, SessionCredentialError>; | |
| 346 | readonly authenticateCookie: ( | |
| 347 | request: HttpServerRequest.HttpServerRequest, | |
| 348 | ) => Effect.Effect<AuthContext, SessionCredentialError>; | |
| 349 | readonly authenticateBearer: ( | |
| 350 | request: HttpServerRequest.HttpServerRequest, | |
| 351 | ) => Effect.Effect<AuthContext, SessionCredentialError>; | |
| 352 | } | |
| 353 | ``` | |
| 354 | ||
| 355 | ### `ServerAuthPolicy` | |
| 356 | ||
| 357 | Pure policy/config service that decides which credential types are allowed. | |
| 358 | ||
| 359 | Responsibilities: | |
| 360 | ||
| 361 | - map runtime mode and bind/exposure settings to allowed auth methods | |
| 362 | - answer whether a route can be public | |
| 363 | - answer whether remote exposure requires auth | |
| 364 | ||
| 365 | This should stay mostly pure and cheap to test. | |
| 366 | ||
| 367 | ### `ServerSecretStore` | |
| 368 | ||
| 369 | Owns long-lived server signing keys and secrets. | |
| 370 | ||
| 371 | Responsibilities: | |
| 372 | ||
| 373 | - get or create signing key | |
| 374 | - rotate signing key | |
| 375 | - abstract secure OS-backed storage vs filesystem fallback | |
| 376 | ||
| 377 | Important: | |
| 378 | ||
| 379 | - prefer platform secure storage when available | |
| 380 | - support hardened filesystem fallback for headless/server-only environments | |
| 381 | ||
| 382 | ### `BrowserSessionCookieCodec` | |
| 383 | ||
| 384 | Focused utility service for cookie encode/decode/signing behavior. | |
| 385 | ||
| 386 | This should not own policy. It should only own the cookie format. | |
| 387 | ||
| 388 | ### `AuthRouteGuards` | |
| 389 | ||
| 390 | Thin helper layer used by routes to enforce auth consistently. | |
| 391 | ||
| 392 | Responsibilities: | |
| 393 | ||
| 394 | - require auth for HTTP route handlers | |
| 395 | - classify route auth mode | |
| 396 | - convert auth failures into `401` / `403` | |
| 397 | ||
| 398 | This prevents every route from re-implementing the same pattern. | |
| 399 | ||
| 400 | Integrates with `HttpRouter.middleware` to enforce auth consistently. | |
| 401 | ||
| 402 | ## Suggested layer graph | |
| 403 | ||
| 404 | ```text | |
| 405 | ServerSecretStore | |
| 406 | ├─> BootstrapCredentialService | |
| 407 | ├─> BrowserSessionCookieCodec | |
| 408 | └─> SessionCredentialService | |
| 409 | ||
| 410 | ServerAuthPolicy | |
| 411 | ├─> BootstrapCredentialService | |
| 412 | ├─> SessionCredentialService | |
| 413 | └─> ServerAuth | |
| 414 | ||
| 415 | ServerAuth | |
| 416 | └─> AuthRouteGuards | |
| 417 | ``` | |
| 418 | ||
| 419 | Layer naming should follow existing repo style: | |
| 420 | ||
| 421 | - `ServerSecretStoreLive` | |
| 422 | - `BootstrapCredentialServiceLive` | |
| 423 | - `SessionCredentialServiceLive` | |
| 424 | - `ServerAuthPolicyLive` | |
| 425 | - `ServerAuthLive` | |
| 426 | - `AuthRouteGuardsLive` | |
| 427 | ||
| 428 | ## High-level implementation examples | |
| 429 | ||
| 430 | ### Example: WebSocket upgrade auth | |
| 431 | ||
| 432 | Current state: | |
| 433 | ||
| 434 | - `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts) | |
| 435 | ||
| 436 | Target shape: | |
| 437 | ||
| 438 | ```ts | |
| 439 | const websocketUpgradeAuth = HttpMiddleware.make((httpApp) => | |
| 440 | Effect.gen(function* () { | |
| 441 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 442 | const serverAuth = yield* ServerAuth; | |
| 443 | yield* serverAuth.authenticateWebSocketUpgrade(request); | |
| 444 | return yield* httpApp; | |
| 445 | }), | |
| 446 | ); | |
| 447 | ``` | |
| 448 | ||
| 449 | Then the `/ws` route becomes: | |
| 450 | ||
| 451 | ```ts | |
| 452 | export const websocketRpcRouteLayer = HttpRouter.add( | |
| 453 | "GET", | |
| 454 | "/ws", | |
| 455 | rpcWebSocketHttpEffect.pipe( | |
| 456 | websocketUpgradeAuth, | |
| 457 | Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), | |
| 458 | ), | |
| 459 | ); | |
| 460 | ``` | |
| 461 | ||
| 462 | This keeps the route itself declarative and makes auth compose like normal HTTP middleware. | |
| 463 | ||
| 464 | ### Example: authenticated HTTP route | |
| 465 | ||
| 466 | For routes like attachments or project favicon: | |
| 467 | ||
| 468 | ```ts | |
| 469 | const authenticatedRoute = (routeClass: RouteAuthClass) => | |
| 470 | HttpMiddleware.make((httpApp) => | |
| 471 | Effect.gen(function* () { | |
| 472 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 473 | const serverAuth = yield* ServerAuth; | |
| 474 | yield* serverAuth.authenticateHttpRequest(request, routeClass); | |
| 475 | return yield* httpApp; | |
| 476 | }), | |
| 477 | ); | |
| 478 | ``` | |
| 479 | ||
| 480 | Then: | |
| 481 | ||
| 482 | ```ts | |
| 483 | export const attachmentsRouteLayer = HttpRouter.add( | |
| 484 | "GET", | |
| 485 | `${ATTACHMENTS_ROUTE_PREFIX}/*`, | |
| 486 | serveAttachment.pipe( | |
| 487 | authenticatedRoute(RouteAuthClass.Authenticated), | |
| 488 | Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), | |
| 489 | ), | |
| 490 | ); | |
| 491 | ``` | |
| 492 | ||
| 493 | ### Example: desktop bootstrap exchange | |
| 494 | ||
| 495 | The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel. | |
| 496 | ||
| 497 | That grant is then exchanged for a browser cookie session when the renderer loads. | |
| 498 | ||
| 499 | Sketch: | |
| 500 | ||
| 501 | ```ts | |
| 502 | const pairDesktopRenderer = Effect.gen(function* () { | |
| 503 | const bootstrapService = yield* BootstrapCredentialService; | |
| 504 | const credential = yield* bootstrapService.issueDesktopBootstrap({ | |
| 505 | audience: "desktop-renderer", | |
| 506 | ttlMs: 30_000, | |
| 507 | }); | |
| 508 | return credential; | |
| 509 | }); | |
| 510 | ``` | |
| 511 | ||
| 512 | The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid. | |
| 513 | ||
| 514 | ### Example: one-time pairing URL | |
| 515 | ||
| 516 | For browser-driven pairing: | |
| 517 | ||
| 518 | ```ts | |
| 519 | const createPairingToken = Effect.gen(function* () { | |
| 520 | const bootstrapService = yield* BootstrapCredentialService; | |
| 521 | return yield* bootstrapService.issueOneTimeToken({ | |
| 522 | ttlMs: 5 * 60_000, | |
| 523 | audience: "browser", | |
| 524 | }); | |
| 525 | }); | |
| 526 | ``` | |
| 527 | ||
| 528 | The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it. | |
| 529 | ||
| 530 | ## Sequence diagrams | |
| 531 | ||
| 532 | These flows are meant to anchor the auth model in concrete user journeys. | |
| 533 | ||
| 534 | The important invariant across all of them is: | |
| 535 | ||
| 536 | - access method is not the auth method | |
| 537 | - launch method is not the auth method | |
| 538 | - bootstrap credential is not the session credential | |
| 539 | ||
| 540 | ### Normal desktop user | |
| 541 | ||
| 542 | This is the default desktop-managed local flow. | |
| 543 | ||
| 544 | The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie. | |
| 545 | ||
| 546 | ```text | |
| 547 | Participants: | |
| 548 | DesktopMain = Electron main | |
| 549 | SecretStore = secure local secret backend | |
| 550 | T3Server = local backend child process | |
| 551 | Frontend = desktop renderer | |
| 552 | ||
| 553 | DesktopMain -> SecretStore : getOrCreate("server-signing-key") | |
| 554 | SecretStore --> DesktopMain : signing key available | |
| 555 | ||
| 556 | DesktopMain -> T3Server : spawn server (--bootstrap-fd ...) | |
| 557 | DesktopMain -> T3Server : send desktop bootstrap envelope | |
| 558 | note over T3Server : policy = DesktopManagedLocalPolicy | |
| 559 | note over T3Server : allowed pairing = desktop-bootstrap only | |
| 560 | ||
| 561 | Frontend -> DesktopMain : request local bootstrap grant | |
| 562 | DesktopMain --> Frontend : short-lived desktop bootstrap grant | |
| 563 | ||
| 564 | Frontend -> T3Server : POST /api/auth/bootstrap | |
| 565 | T3Server -> T3Server : validate desktop bootstrap grant | |
| 566 | T3Server -> T3Server : create browser session | |
| 567 | T3Server --> Frontend : Set-Cookie: session=... | |
| 568 | ||
| 569 | Frontend -> T3Server : GET /ws + authenticated cookie | |
| 570 | T3Server -> T3Server : validate cookie session | |
| 571 | T3Server --> Frontend : websocket accepted | |
| 572 | ``` | |
| 573 | ||
| 574 | ### `npx t3` user | |
| 575 | ||
| 576 | This is the standalone local server flow. | |
| 577 | ||
| 578 | There is no trusted desktop shell here, so pairing should be explicit. | |
| 579 | ||
| 580 | ```text | |
| 581 | Participants: | |
| 582 | UserShell = npx t3 launcher | |
| 583 | T3Server = standalone local server | |
| 584 | Browser = browser tab | |
| 585 | ||
| 586 | UserShell -> T3Server : start server | |
| 587 | T3Server -> T3Server : getOrCreate("server-signing-key") | |
| 588 | note over T3Server : policy = LoopbackBrowserPolicy | |
| 589 | ||
| 590 | UserShell -> T3Server : issue one-time pairing token | |
| 591 | T3Server --> UserShell : pairing URL or pairing token | |
| 592 | ||
| 593 | UserShell --> Browser : open /pair?token=... | |
| 594 | ||
| 595 | Browser -> T3Server : GET /pair?token=... | |
| 596 | T3Server -> T3Server : validate one-time token | |
| 597 | T3Server -> T3Server : create browser session | |
| 598 | T3Server --> Browser : Set-Cookie: session=... | |
| 599 | T3Server --> Browser : redirect to app | |
| 600 | ||
| 601 | Browser -> T3Server : GET /ws + authenticated cookie | |
| 602 | T3Server --> Browser : websocket accepted | |
| 603 | ``` | |
| 604 | ||
| 605 | ### Phone user with tunneled host | |
| 606 | ||
| 607 | This is the explicit remote access flow for a browser on another device. | |
| 608 | ||
| 609 | The tunnel only provides reachability. It must not imply trust. | |
| 610 | ||
| 611 | Recommended UX: | |
| 612 | ||
| 613 | - desktop shows a QR code | |
| 614 | - desktop also shows a copyable pairing URL | |
| 615 | - if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access | |
| 616 | ||
| 617 | ```text | |
| 618 | Participants: | |
| 619 | DesktopUser = user at the host machine | |
| 620 | DesktopMain = desktop app | |
| 621 | Tunnel = tunnel provider | |
| 622 | T3Server = T3 server | |
| 623 | PhoneBrowser = mobile browser | |
| 624 | ||
| 625 | DesktopUser -> DesktopMain : enable remote access via tunnel | |
| 626 | DesktopMain -> T3Server : switch policy to RemoteReachablePolicy | |
| 627 | DesktopMain -> Tunnel : publish local T3 endpoint | |
| 628 | Tunnel --> DesktopMain : public https/wss URL | |
| 629 | ||
| 630 | DesktopMain -> T3Server : issue one-time pairing token | |
| 631 | T3Server --> DesktopMain : pairing token | |
| 632 | DesktopMain -> DesktopUser : show QR code / shareable URL | |
| 633 | ||
| 634 | DesktopUser -> PhoneBrowser : scan QR / open URL | |
| 635 | PhoneBrowser -> Tunnel : GET https://public-host/pair?token=... | |
| 636 | Tunnel -> T3Server : forward request | |
| 637 | T3Server -> T3Server : validate one-time token | |
| 638 | T3Server -> T3Server : create mobile browser session | |
| 639 | T3Server --> PhoneBrowser : Set-Cookie: session=... | |
| 640 | T3Server --> PhoneBrowser : redirect to app | |
| 641 | ||
| 642 | PhoneBrowser -> Tunnel : GET /ws + authenticated cookie | |
| 643 | Tunnel -> T3Server : forward websocket upgrade | |
| 644 | T3Server --> PhoneBrowser : websocket accepted | |
| 645 | ``` | |
| 646 | ||
| 647 | ### Phone user with private network | |
| 648 | ||
| 649 | This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale. | |
| 650 | ||
| 651 | The auth flow should stay the same. | |
| 652 | ||
| 653 | ```text | |
| 654 | Participants: | |
| 655 | DesktopUser = user at the host machine | |
| 656 | T3Server = T3 server | |
| 657 | PrivateNet = tailscale / private LAN | |
| 658 | PhoneBrowser = mobile browser | |
| 659 | ||
| 660 | DesktopUser -> T3Server : enable private-network access | |
| 661 | T3Server -> T3Server : switch policy to RemoteReachablePolicy | |
| 662 | DesktopUser -> T3Server : issue one-time pairing token | |
| 663 | T3Server --> DesktopUser : pairing URL / QR | |
| 664 | ||
| 665 | DesktopUser -> PhoneBrowser : open private-network URL | |
| 666 | PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=... | |
| 667 | PrivateNet -> T3Server : route request | |
| 668 | T3Server -> T3Server : validate one-time token | |
| 669 | T3Server -> T3Server : create mobile browser session | |
| 670 | T3Server --> PhoneBrowser : Set-Cookie: session=... | |
| 671 | T3Server --> PhoneBrowser : redirect to app | |
| 672 | ||
| 673 | PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie | |
| 674 | PrivateNet -> T3Server : websocket upgrade | |
| 675 | T3Server --> PhoneBrowser : websocket accepted | |
| 676 | ``` | |
| 677 | ||
| 678 | ### Desktop user adding new SSH hosts | |
| 679 | ||
| 680 | SSH should be treated as launch and reachability plumbing, not as the long-term auth model. | |
| 681 | ||
| 682 | The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment. | |
| 683 | ||
| 684 | ```text | |
| 685 | Participants: | |
| 686 | DesktopUser = local desktop user | |
| 687 | DesktopMain = desktop app | |
| 688 | SSH = ssh transport/session | |
| 689 | RemoteHost = remote machine | |
| 690 | RemoteT3 = remote T3 server | |
| 691 | Frontend = desktop renderer | |
| 692 | ||
| 693 | DesktopUser -> DesktopMain : add SSH host | |
| 694 | DesktopMain -> SSH : connect to remote host | |
| 695 | SSH -> RemoteHost : probe environment / verify t3 availability | |
| 696 | DesktopMain -> SSH : run remote launch command | |
| 697 | SSH -> RemoteHost : t3 remote launch --json | |
| 698 | RemoteHost -> RemoteT3 : start or reuse server | |
| 699 | RemoteT3 --> RemoteHost : port + environment metadata | |
| 700 | RemoteHost --> SSH : launch result JSON | |
| 701 | SSH --> DesktopMain : remote server details | |
| 702 | ||
| 703 | DesktopMain -> SSH : establish local port forward | |
| 704 | SSH --> DesktopMain : localhost:FORWARDED_PORT ready | |
| 705 | ||
| 706 | note over RemoteT3 : policy = RemoteReachablePolicy | |
| 707 | note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here | |
| 708 | ||
| 709 | Frontend -> DesktopMain : request bootstrap for selected environment | |
| 710 | DesktopMain --> Frontend : short-lived bootstrap grant | |
| 711 | ||
| 712 | Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port | |
| 713 | RemoteT3 -> RemoteT3 : validate bootstrap grant | |
| 714 | RemoteT3 -> RemoteT3 : create browser session | |
| 715 | RemoteT3 --> Frontend : Set-Cookie: session=... | |
| 716 | ||
| 717 | Frontend -> RemoteT3 : GET /ws + authenticated cookie | |
| 718 | RemoteT3 --> Frontend : websocket accepted | |
| 719 | ``` | |
| 720 | ||
| 721 | ## Storage decisions | |
| 722 | ||
| 723 | ### Server secrets | |
| 724 | ||
| 725 | Use a `ServerSecretStore` abstraction. | |
| 726 | ||
| 727 | Preferred order (use a layer for each, resolve on startup): | |
| 728 | ||
| 729 | 1. OS secure storage if available | |
| 730 | 2. hardened filesystem fallback if not | |
| 731 | ||
| 732 | The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials. | |
| 733 | ||
| 734 | ### Client credentials | |
| 735 | ||
| 736 | Client-side credential persistence should prefer secure storage when available: | |
| 737 | ||
| 738 | - desktop: OS keychain / secure store | |
| 739 | - mobile: platform secure storage | |
| 740 | - browser: cookie session for browser auth | |
| 741 | ||
| 742 | This concern should stay in the client shell/runtime layer, not the server auth layer. | |
| 743 | ||
| 744 | ## What to build now | |
| 745 | ||
| 746 | These are the parts worth building before remote environments ship: | |
| 747 | ||
| 748 | 1. `ServerAuth` service boundary. | |
| 749 | 2. route classification and route guards. | |
| 750 | 3. `ServerSecretStore` abstraction. | |
| 751 | 4. bootstrap vs session credential split. | |
| 752 | 5. browser session cookie codec as one session method. | |
| 753 | 6. explicit auth capabilities/config surfaced in contracts. | |
| 754 | ||
| 755 | Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained. | |
| 756 | ||
| 757 | ## What to add as part of first remote-capable auth | |
| 758 | ||
| 759 | 1. Browser pairing flow using one-time bootstrap token and cookie session. | |
| 760 | 2. Desktop-managed auto-bootstrap for the local desktop-managed environment. | |
| 761 | 3. Auth-required defaults for any non-loopback or explicitly published server. | |
| 762 | 4. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks. | |
| 763 | ||
| 764 | ## What to defer | |
| 765 | ||
| 766 | - passkeys / WebAuthn | |
| 767 | - iCloud Keychain / Face ID-specific UX | |
| 768 | - multi-user permissions | |
| 769 | - collaboration roles | |
| 770 | - OAuth / SSO | |
| 771 | - polished session management UI | |
| 772 | - complex device approval flows | |
| 773 | ||
| 774 | These can all sit on top of the same bootstrap/session/service split. | |
| 775 | ||
| 776 | ## Relationship to future remote environments | |
| 777 | ||
| 778 | Remote access is one reason this auth model matters, but the auth model should not be remote-shaped. | |
| 779 | ||
| 780 | Keep the design focused on: | |
| 781 | ||
| 782 | - one T3 server | |
| 783 | - one auth policy | |
| 784 | - multiple credential types | |
| 785 | - multiple future access methods | |
| 786 | ||
| 787 | That keeps the server auth model stable even as access methods expand later. | |
| 788 | ||
| 789 | ## Recommended implementation order | |
| 790 | ||
| 791 | ### Phase 1 | |
| 792 | ||
| 793 | - Introduce route auth classes. | |
| 794 | - Add `ServerAuth` and `AuthRouteGuards`. | |
| 795 | - Move existing `authToken` check behind `ServerAuth`. | |
| 796 | - Require auth for all privileged HTTP routes as well as WebSocket. | |
| 797 | ||
| 798 | ### Phase 2 | |
| 799 | ||
| 800 | - Add `ServerSecretStore` service with platform-specific layer implementations. | |
| 801 | - `layerOSXKeychain`, `layer | |
| 802 | - Add bootstrap/session split. | |
| 803 | - Add browser session cookie support. | |
| 804 | - Add one-time bootstrap exchange endpoint. | |
| 805 | ||
| 806 | ### Phase 3 | |
| 807 | ||
| 808 | - Add desktop bootstrap flow on top of the same services. | |
| 809 | - Make desktop-managed local environments default to bootstrap-only pairing. | |
| 810 | - Surface auth capabilities in shared contracts and renderer bootstrap. | |
| 811 | ||
| 812 | ### Phase 4 | |
| 813 | ||
| 814 | - Add non-browser bearer session support if mobile/native needs it. | |
| 815 | - Add richer policy modes for remote-reachable environments. | |
| 816 | ||
| 817 | ## Acceptance criteria | |
| 818 | ||
| 819 | - No privileged HTTP or WebSocket path bypasses auth policy. | |
| 820 | - Local desktop-managed flows still avoid a visible login screen. | |
| 821 | - Non-loopback or published environments require explicit authenticated pairing by default. | |
| 822 | - Bootstrap and session credentials are distinct in code and in behavior. | |
| 823 | - Auth logic is centralized in Effect services/layers rather than route-local branching. |
apps/desktop/scripts/dev-electron.mjs+13-6| ... | ... | @@ -5,8 +5,17 @@ import { join } from "node:path"; |
| 5 | 5 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; |
| 6 | 6 | import { waitForResources } from "./wait-for-resources.mjs"; |
| 7 | 7 | |
| 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); | |
| 9 | const devServerUrl = `http://localhost:${port}`; | |
| 8 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); | |
| 9 | if (!devServerUrl) { | |
| 10 | throw new Error("VITE_DEV_SERVER_URL is required for desktop development."); | |
| 11 | } | |
| 12 | ||
| 13 | const devServer = new URL(devServerUrl); | |
| 14 | const port = Number.parseInt(devServer.port, 10); | |
| 15 | if (!Number.isInteger(port) || port <= 0) { | |
| 16 | throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`); | |
| 17 | } | |
| 18 | ||
| 10 | 19 | const requiredFiles = [ |
| 11 | 20 | "dist-electron/main.js", |
| 12 | 21 | "dist-electron/preload.js", |
| ... | ... | @@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200; |
| 23 | 32 | await waitForResources({ |
| 24 | 33 | baseDir: desktopDir, |
| 25 | 34 | files: requiredFiles, |
| 35 | tcpHost: devServer.hostname, | |
| 26 | 36 | tcpPort: port, |
| 27 | 37 | }); |
| 28 | 38 | |
| ... | ... | @@ -62,10 +72,7 @@ function startApp() { |
| 62 | 72 | [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], |
| 63 | 73 | { |
| 64 | 74 | cwd: desktopDir, |
| 65 | env: { | |
| 66 | ...childEnv, | |
| 67 | VITE_DEV_SERVER_URL: devServerUrl, | |
| 68 | }, | |
| 75 | env: childEnv, | |
| 69 | 76 | stdio: "inherit", |
| 70 | 77 | }, |
| 71 | 78 | ); |
apps/desktop/src/backendReadiness.test.ts created+83| ... | ... | @@ -0,0 +1,83 @@ |
| 1 | import { describe, expect, it, vi } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | BackendReadinessAbortedError, | |
| 5 | isBackendReadinessAborted, | |
| 6 | waitForHttpReady, | |
| 7 | } from "./backendReadiness"; | |
| 8 | ||
| 9 | describe("waitForHttpReady", () => { | |
| 10 | it("returns once the backend reports a successful session endpoint", async () => { | |
| 11 | const fetchImpl = vi | |
| 12 | .fn<typeof fetch>() | |
| 13 | .mockResolvedValueOnce(new Response(null, { status: 503 })) | |
| 14 | .mockResolvedValueOnce(new Response(null, { status: 200 })); | |
| 15 | ||
| 16 | await waitForHttpReady("http://127.0.0.1:3773", { | |
| 17 | fetchImpl, | |
| 18 | timeoutMs: 1_000, | |
| 19 | intervalMs: 0, | |
| 20 | }); | |
| 21 | ||
| 22 | expect(fetchImpl).toHaveBeenCalledTimes(2); | |
| 23 | }); | |
| 24 | ||
| 25 | it("retries after a readiness request stalls past the per-request timeout", async () => { | |
| 26 | const fetchImpl = vi | |
| 27 | .fn<typeof fetch>() | |
| 28 | .mockImplementationOnce( | |
| 29 | (_input, init) => | |
| 30 | new Promise((_resolve, reject) => { | |
| 31 | init?.signal?.addEventListener( | |
| 32 | "abort", | |
| 33 | () => { | |
| 34 | reject(new Error("request timed out")); | |
| 35 | }, | |
| 36 | { once: true }, | |
| 37 | ); | |
| 38 | }) as ReturnType<typeof fetch>, | |
| 39 | ) | |
| 40 | .mockResolvedValueOnce(new Response(null, { status: 200 })); | |
| 41 | ||
| 42 | await waitForHttpReady("http://127.0.0.1:3773", { | |
| 43 | fetchImpl, | |
| 44 | timeoutMs: 100, | |
| 45 | intervalMs: 0, | |
| 46 | requestTimeoutMs: 1, | |
| 47 | }); | |
| 48 | ||
| 49 | expect(fetchImpl).toHaveBeenCalledTimes(2); | |
| 50 | }); | |
| 51 | ||
| 52 | it("aborts an in-flight readiness wait", async () => { | |
| 53 | const controller = new AbortController(); | |
| 54 | const fetchImpl = vi.fn<typeof fetch>().mockImplementation( | |
| 55 | () => | |
| 56 | new Promise((_resolve, reject) => { | |
| 57 | controller.signal.addEventListener( | |
| 58 | "abort", | |
| 59 | () => { | |
| 60 | reject(new BackendReadinessAbortedError()); | |
| 61 | }, | |
| 62 | { once: true }, | |
| 63 | ); | |
| 64 | }) as ReturnType<typeof fetch>, | |
| 65 | ); | |
| 66 | ||
| 67 | const waitPromise = waitForHttpReady("http://127.0.0.1:3773", { | |
| 68 | fetchImpl, | |
| 69 | timeoutMs: 1_000, | |
| 70 | intervalMs: 0, | |
| 71 | signal: controller.signal, | |
| 72 | }); | |
| 73 | ||
| 74 | controller.abort(); | |
| 75 | ||
| 76 | await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError); | |
| 77 | }); | |
| 78 | ||
| 79 | it("recognizes aborted readiness errors", () => { | |
| 80 | expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true); | |
| 81 | expect(isBackendReadinessAborted(new Error("nope"))).toBe(false); | |
| 82 | }); | |
| 83 | }); |
apps/desktop/src/backendReadiness.ts created+103| ... | ... | @@ -0,0 +1,103 @@ |
| 1 | export interface WaitForHttpReadyOptions { | |
| 2 | readonly timeoutMs?: number; | |
| 3 | readonly intervalMs?: number; | |
| 4 | readonly requestTimeoutMs?: number; | |
| 5 | readonly fetchImpl?: typeof fetch; | |
| 6 | readonly signal?: AbortSignal; | |
| 7 | } | |
| 8 | ||
| 9 | const DEFAULT_TIMEOUT_MS = 10_000; | |
| 10 | const DEFAULT_INTERVAL_MS = 100; | |
| 11 | const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; | |
| 12 | ||
| 13 | export class BackendReadinessAbortedError extends Error { | |
| 14 | constructor() { | |
| 15 | super("Backend readiness wait was aborted."); | |
| 16 | this.name = "BackendReadinessAbortedError"; | |
| 17 | } | |
| 18 | } | |
| 19 | ||
| 20 | function delay(ms: number, signal: AbortSignal | undefined): Promise<void> { | |
| 21 | return new Promise<void>((resolve, reject) => { | |
| 22 | const timer = setTimeout(() => { | |
| 23 | cleanup(); | |
| 24 | resolve(); | |
| 25 | }, ms); | |
| 26 | ||
| 27 | const onAbort = () => { | |
| 28 | cleanup(); | |
| 29 | reject(new BackendReadinessAbortedError()); | |
| 30 | }; | |
| 31 | ||
| 32 | const cleanup = () => { | |
| 33 | clearTimeout(timer); | |
| 34 | signal?.removeEventListener("abort", onAbort); | |
| 35 | }; | |
| 36 | ||
| 37 | if (signal?.aborted) { | |
| 38 | cleanup(); | |
| 39 | reject(new BackendReadinessAbortedError()); | |
| 40 | return; | |
| 41 | } | |
| 42 | ||
| 43 | signal?.addEventListener("abort", onAbort, { once: true }); | |
| 44 | }); | |
| 45 | } | |
| 46 | ||
| 47 | export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError { | |
| 48 | return error instanceof BackendReadinessAbortedError; | |
| 49 | } | |
| 50 | ||
| 51 | export async function waitForHttpReady( | |
| 52 | baseUrl: string, | |
| 53 | options?: WaitForHttpReadyOptions, | |
| 54 | ): Promise<void> { | |
| 55 | const fetchImpl = options?.fetchImpl ?? fetch; | |
| 56 | const signal = options?.signal; | |
| 57 | const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; | |
| 58 | const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; | |
| 59 | const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; | |
| 60 | const deadline = Date.now() + timeoutMs; | |
| 61 | ||
| 62 | for (;;) { | |
| 63 | if (signal?.aborted) { | |
| 64 | throw new BackendReadinessAbortedError(); | |
| 65 | } | |
| 66 | ||
| 67 | const requestController = new AbortController(); | |
| 68 | const requestTimeout = setTimeout(() => { | |
| 69 | requestController.abort(); | |
| 70 | }, requestTimeoutMs); | |
| 71 | const abortRequest = () => { | |
| 72 | requestController.abort(); | |
| 73 | }; | |
| 74 | signal?.addEventListener("abort", abortRequest, { once: true }); | |
| 75 | ||
| 76 | try { | |
| 77 | const response = await fetchImpl(`${baseUrl}/api/auth/session`, { | |
| 78 | redirect: "manual", | |
| 79 | signal: requestController.signal, | |
| 80 | }); | |
| 81 | if (response.ok) { | |
| 82 | return; | |
| 83 | } | |
| 84 | } catch (error) { | |
| 85 | if (isBackendReadinessAborted(error)) { | |
| 86 | throw error; | |
| 87 | } | |
| 88 | if (signal?.aborted) { | |
| 89 | throw new BackendReadinessAbortedError(); | |
| 90 | } | |
| 91 | // Retry until the backend becomes reachable or the deadline expires. | |
| 92 | } finally { | |
| 93 | clearTimeout(requestTimeout); | |
| 94 | signal?.removeEventListener("abort", abortRequest); | |
| 95 | } | |
| 96 | ||
| 97 | if (Date.now() >= deadline) { | |
| 98 | throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); | |
| 99 | } | |
| 100 | ||
| 101 | await delay(intervalMs, signal); | |
| 102 | } | |
| 103 | } |
apps/desktop/src/desktopSettings.test.ts created+64| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | import * as fs from "node:fs"; | |
| 2 | import * as os from "node:os"; | |
| 3 | import * as path from "node:path"; | |
| 4 | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | ||
| 7 | import { | |
| 8 | DEFAULT_DESKTOP_SETTINGS, | |
| 9 | readDesktopSettings, | |
| 10 | setDesktopServerExposurePreference, | |
| 11 | writeDesktopSettings, | |
| 12 | } from "./desktopSettings"; | |
| 13 | ||
| 14 | const tempDirectories: string[] = []; | |
| 15 | ||
| 16 | afterEach(() => { | |
| 17 | for (const directory of tempDirectories.splice(0)) { | |
| 18 | fs.rmSync(directory, { recursive: true, force: true }); | |
| 19 | } | |
| 20 | }); | |
| 21 | ||
| 22 | function makeSettingsPath() { | |
| 23 | const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-")); | |
| 24 | tempDirectories.push(directory); | |
| 25 | return path.join(directory, "desktop-settings.json"); | |
| 26 | } | |
| 27 | ||
| 28 | describe("desktopSettings", () => { | |
| 29 | it("returns defaults when no settings file exists", () => { | |
| 30 | expect(readDesktopSettings(makeSettingsPath())).toEqual(DEFAULT_DESKTOP_SETTINGS); | |
| 31 | }); | |
| 32 | ||
| 33 | it("persists and reloads the configured server exposure mode", () => { | |
| 34 | const settingsPath = makeSettingsPath(); | |
| 35 | ||
| 36 | writeDesktopSettings(settingsPath, { | |
| 37 | serverExposureMode: "network-accessible", | |
| 38 | }); | |
| 39 | ||
| 40 | expect(readDesktopSettings(settingsPath)).toEqual({ | |
| 41 | serverExposureMode: "network-accessible", | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | it("preserves the requested network-accessible preference across temporary fallback", () => { | |
| 46 | expect( | |
| 47 | setDesktopServerExposurePreference( | |
| 48 | { | |
| 49 | serverExposureMode: "local-only", | |
| 50 | }, | |
| 51 | "network-accessible", | |
| 52 | ), | |
| 53 | ).toEqual({ | |
| 54 | serverExposureMode: "network-accessible", | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | it("falls back to defaults when the settings file is malformed", () => { | |
| 59 | const settingsPath = makeSettingsPath(); | |
| 60 | fs.writeFileSync(settingsPath, "{not-json", "utf8"); | |
| 61 | ||
| 62 | expect(readDesktopSettings(settingsPath)).toEqual(DEFAULT_DESKTOP_SETTINGS); | |
| 63 | }); | |
| 64 | }); |
apps/desktop/src/desktopSettings.ts created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | import * as FS from "node:fs"; | |
| 2 | import * as Path from "node:path"; | |
| 3 | import type { DesktopServerExposureMode } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | export interface DesktopSettings { | |
| 6 | readonly serverExposureMode: DesktopServerExposureMode; | |
| 7 | } | |
| 8 | ||
| 9 | export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { | |
| 10 | serverExposureMode: "local-only", | |
| 11 | }; | |
| 12 | ||
| 13 | export function setDesktopServerExposurePreference( | |
| 14 | settings: DesktopSettings, | |
| 15 | requestedMode: DesktopServerExposureMode, | |
| 16 | ): DesktopSettings { | |
| 17 | return settings.serverExposureMode === requestedMode | |
| 18 | ? settings | |
| 19 | : { | |
| 20 | ...settings, | |
| 21 | serverExposureMode: requestedMode, | |
| 22 | }; | |
| 23 | } | |
| 24 | ||
| 25 | export function readDesktopSettings(settingsPath: string): DesktopSettings { | |
| 26 | try { | |
| 27 | if (!FS.existsSync(settingsPath)) { | |
| 28 | return DEFAULT_DESKTOP_SETTINGS; | |
| 29 | } | |
| 30 | ||
| 31 | const raw = FS.readFileSync(settingsPath, "utf8"); | |
| 32 | const parsed = JSON.parse(raw) as { | |
| 33 | readonly serverExposureMode?: unknown; | |
| 34 | }; | |
| 35 | ||
| 36 | return { | |
| 37 | serverExposureMode: | |
| 38 | parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", | |
| 39 | }; | |
| 40 | } catch { | |
| 41 | return DEFAULT_DESKTOP_SETTINGS; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void { | |
| 46 | const directory = Path.dirname(settingsPath); | |
| 47 | const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; | |
| 48 | FS.mkdirSync(directory, { recursive: true }); | |
| 49 | FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); | |
| 50 | FS.renameSync(tempPath, settingsPath); | |
| 51 | } |
apps/desktop/src/main.ts+291-31| ... | ... | @@ -19,6 +19,8 @@ import type { MenuItemConstructorOptions } from "electron"; |
| 19 | 19 | import * as Effect from "effect/Effect"; |
| 20 | 20 | import type { |
| 21 | 21 | DesktopTheme, |
| 22 | DesktopServerExposureMode, | |
| 23 | DesktopServerExposureState, | |
| 22 | 24 | DesktopUpdateActionResult, |
| 23 | 25 | DesktopUpdateCheckResult, |
| 24 | 26 | DesktopUpdateState, |
| ... | ... | @@ -29,7 +31,15 @@ import type { ContextMenuItem } from "@t3tools/contracts"; |
| 29 | 31 | import { NetService } from "@t3tools/shared/Net"; |
| 30 | 32 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 31 | 33 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 34 | import { | |
| 35 | DEFAULT_DESKTOP_SETTINGS, | |
| 36 | readDesktopSettings, | |
| 37 | setDesktopServerExposurePreference, | |
| 38 | writeDesktopSettings, | |
| 39 | } from "./desktopSettings"; | |
| 40 | import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; | |
| 32 | 41 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 42 | import { resolveDesktopServerExposure } from "./serverExposure"; | |
| 33 | 43 | import { syncShellEnvironment } from "./syncShellEnvironment"; |
| 34 | 44 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; |
| 35 | 45 | import { |
| ... | ... | @@ -59,10 +69,12 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 59 | 69 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 60 | 70 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 61 | 71 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 62 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | |
| 63 | 72 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; |
| 73 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | |
| 74 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | |
| 64 | 75 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 65 | 76 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| 77 | const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); | |
| 66 | 78 | const DESKTOP_SCHEME = "t3"; |
| 67 | 79 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); |
| 68 | 80 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| ... | ... | @@ -83,6 +95,7 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; |
| 83 | 95 | const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; |
| 84 | 96 | const DESKTOP_UPDATE_CHANNEL = "latest"; |
| 85 | 97 | const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; |
| 98 | const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | |
| 86 | 99 | |
| 87 | 100 | type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; |
| 88 | 101 | type LinuxDesktopNamedApp = Electron.App & { |
| ... | ... | @@ -92,8 +105,13 @@ type LinuxDesktopNamedApp = Electron.App & { |
| 92 | 105 | let mainWindow: BrowserWindow | null = null; |
| 93 | 106 | let backendProcess: ChildProcess.ChildProcess | null = null; |
| 94 | 107 | let backendPort = 0; |
| 95 | let backendAuthToken = ""; | |
| 108 | let backendBindHost = DESKTOP_LOOPBACK_HOST; | |
| 109 | let backendBootstrapToken = ""; | |
| 110 | let backendHttpUrl = ""; | |
| 96 | 111 | let backendWsUrl = ""; |
| 112 | let backendEndpointUrl: string | null = null; | |
| 113 | let backendAdvertisedHost: string | null = null; | |
| 114 | let backendReadinessAbortController: AbortController | null = null; | |
| 97 | 115 | let restartAttempt = 0; |
| 98 | 116 | let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 99 | 117 | let isQuitting = false; |
| ... | ... | @@ -103,6 +121,8 @@ let desktopLogSink: RotatingFileSink | null = null; |
| 103 | 121 | let backendLogSink: RotatingFileSink | null = null; |
| 104 | 122 | let restoreStdIoCapture: (() => void) | null = null; |
| 105 | 123 | let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); |
| 124 | let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH); | |
| 125 | let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; | |
| 106 | 126 | |
| 107 | 127 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; |
| 108 | 128 | const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>(); |
| ... | ... | @@ -141,17 +161,120 @@ function readPersistedBackendObservabilitySettings(): { |
| 141 | 161 | } |
| 142 | 162 | } |
| 143 | 163 | |
| 164 | function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { | |
| 165 | if (!rawPort) { | |
| 166 | return undefined; | |
| 167 | } | |
| 168 | ||
| 169 | const parsedPort = Number.parseInt(rawPort, 10); | |
| 170 | if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { | |
| 171 | return undefined; | |
| 172 | } | |
| 173 | ||
| 174 | return parsedPort; | |
| 175 | } | |
| 176 | ||
| 177 | function resolveDesktopDevServerUrl(): string { | |
| 178 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); | |
| 179 | if (!devServerUrl) { | |
| 180 | throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); | |
| 181 | } | |
| 182 | ||
| 183 | return devServerUrl; | |
| 184 | } | |
| 185 | ||
| 144 | 186 | function backendChildEnv(): NodeJS.ProcessEnv { |
| 145 | 187 | const env = { ...process.env }; |
| 146 | 188 | delete env.T3CODE_PORT; |
| 147 | delete env.T3CODE_AUTH_TOKEN; | |
| 148 | 189 | delete env.T3CODE_MODE; |
| 149 | 190 | delete env.T3CODE_NO_BROWSER; |
| 150 | 191 | delete env.T3CODE_HOST; |
| 151 | 192 | delete env.T3CODE_DESKTOP_WS_URL; |
| 193 | delete env.T3CODE_DESKTOP_LAN_ACCESS; | |
| 194 | delete env.T3CODE_DESKTOP_LAN_HOST; | |
| 152 | 195 | return env; |
| 153 | 196 | } |
| 154 | 197 | |
| 198 | function getDesktopServerExposureState(): DesktopServerExposureState { | |
| 199 | return { | |
| 200 | mode: desktopServerExposureMode, | |
| 201 | endpointUrl: backendEndpointUrl, | |
| 202 | advertisedHost: backendAdvertisedHost, | |
| 203 | }; | |
| 204 | } | |
| 205 | ||
| 206 | function resolveAdvertisedHostOverride(): string | undefined { | |
| 207 | const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); | |
| 208 | return override && override.length > 0 ? override : undefined; | |
| 209 | } | |
| 210 | ||
| 211 | async function applyDesktopServerExposureMode( | |
| 212 | mode: DesktopServerExposureMode, | |
| 213 | options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean }, | |
| 214 | ): Promise<DesktopServerExposureState> { | |
| 215 | const advertisedHostOverride = resolveAdvertisedHostOverride(); | |
| 216 | const requestedMode = mode; | |
| 217 | let exposure = resolveDesktopServerExposure({ | |
| 218 | mode, | |
| 219 | port: backendPort, | |
| 220 | networkInterfaces: OS.networkInterfaces(), | |
| 221 | ...(advertisedHostOverride ? { advertisedHostOverride } : {}), | |
| 222 | }); | |
| 223 | ||
| 224 | if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { | |
| 225 | if (options?.rejectIfUnavailable) { | |
| 226 | throw new Error("No reachable network address is available for this desktop right now."); | |
| 227 | } | |
| 228 | exposure = resolveDesktopServerExposure({ | |
| 229 | mode: "local-only", | |
| 230 | port: backendPort, | |
| 231 | networkInterfaces: OS.networkInterfaces(), | |
| 232 | ...(advertisedHostOverride ? { advertisedHostOverride } : {}), | |
| 233 | }); | |
| 234 | } | |
| 235 | ||
| 236 | desktopServerExposureMode = exposure.mode; | |
| 237 | desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); | |
| 238 | backendBindHost = exposure.bindHost; | |
| 239 | backendHttpUrl = exposure.localHttpUrl; | |
| 240 | backendWsUrl = exposure.localWsUrl; | |
| 241 | backendEndpointUrl = exposure.endpointUrl; | |
| 242 | backendAdvertisedHost = exposure.advertisedHost; | |
| 243 | ||
| 244 | if (options?.persist) { | |
| 245 | writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); | |
| 246 | } | |
| 247 | ||
| 248 | return getDesktopServerExposureState(); | |
| 249 | } | |
| 250 | ||
| 251 | function relaunchDesktopApp(reason: string): void { | |
| 252 | writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); | |
| 253 | setImmediate(() => { | |
| 254 | isQuitting = true; | |
| 255 | clearUpdatePollTimer(); | |
| 256 | cancelBackendReadinessWait(); | |
| 257 | void stopBackendAndWaitForExit() | |
| 258 | .catch((error) => { | |
| 259 | writeDesktopLogHeader( | |
| 260 | `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, | |
| 261 | ); | |
| 262 | }) | |
| 263 | .finally(() => { | |
| 264 | restoreStdIoCapture?.(); | |
| 265 | if (isDevelopment) { | |
| 266 | app.exit(75); | |
| 267 | return; | |
| 268 | } | |
| 269 | app.relaunch({ | |
| 270 | execPath: process.execPath, | |
| 271 | args: process.argv.slice(1), | |
| 272 | }); | |
| 273 | app.exit(0); | |
| 274 | }); | |
| 275 | }); | |
| 276 | } | |
| 277 | ||
| 155 | 278 | function writeDesktopLogHeader(message: string): void { |
| 156 | 279 | if (!desktopLogSink) return; |
| 157 | 280 | desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); |
| ... | ... | @@ -199,6 +322,27 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { |
| 199 | 322 | return null; |
| 200 | 323 | } |
| 201 | 324 | |
| 325 | async function waitForBackendHttpReady(baseUrl: string): Promise<void> { | |
| 326 | cancelBackendReadinessWait(); | |
| 327 | const controller = new AbortController(); | |
| 328 | backendReadinessAbortController = controller; | |
| 329 | ||
| 330 | try { | |
| 331 | await waitForHttpReady(baseUrl, { | |
| 332 | signal: controller.signal, | |
| 333 | }); | |
| 334 | } finally { | |
| 335 | if (backendReadinessAbortController === controller) { | |
| 336 | backendReadinessAbortController = null; | |
| 337 | } | |
| 338 | } | |
| 339 | } | |
| 340 | ||
| 341 | function cancelBackendReadinessWait(): void { | |
| 342 | backendReadinessAbortController?.abort(); | |
| 343 | backendReadinessAbortController = null; | |
| 344 | } | |
| 345 | ||
| 202 | 346 | function writeDesktopStreamChunk( |
| 203 | 347 | streamName: "stdout" | "stderr", |
| 204 | 348 | chunk: unknown, |
| ... | ... | @@ -543,10 +687,7 @@ function dispatchMenuAction(action: string): void { |
| 543 | 687 | const send = () => { |
| 544 | 688 | if (targetWindow.isDestroyed()) return; |
| 545 | 689 | targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); |
| 546 | if (!targetWindow.isVisible()) { | |
| 547 | targetWindow.show(); | |
| 548 | } | |
| 549 | targetWindow.focus(); | |
| 690 | revealWindow(targetWindow); | |
| 550 | 691 | }; |
| 551 | 692 | |
| 552 | 693 | if (targetWindow.webContents.isLoadingMainFrame()) { |
| ... | ... | @@ -767,6 +908,26 @@ function clearUpdatePollTimer(): void { |
| 767 | 908 | } |
| 768 | 909 | } |
| 769 | 910 | |
| 911 | function revealWindow(window: BrowserWindow): void { | |
| 912 | if (window.isDestroyed()) { | |
| 913 | return; | |
| 914 | } | |
| 915 | ||
| 916 | if (window.isMinimized()) { | |
| 917 | window.restore(); | |
| 918 | } | |
| 919 | ||
| 920 | if (!window.isVisible()) { | |
| 921 | window.show(); | |
| 922 | } | |
| 923 | ||
| 924 | if (process.platform === "darwin") { | |
| 925 | app.focus({ steal: true }); | |
| 926 | } | |
| 927 | ||
| 928 | window.focus(); | |
| 929 | } | |
| 930 | ||
| 770 | 931 | function emitUpdateState(): void { |
| 771 | 932 | for (const window of BrowserWindow.getAllWindows()) { |
| 772 | 933 | if (window.isDestroyed()) continue; |
| ... | ... | @@ -1036,7 +1197,8 @@ function startBackend(): void { |
| 1036 | 1197 | noBrowser: true, |
| 1037 | 1198 | port: backendPort, |
| 1038 | 1199 | t3Home: BASE_DIR, |
| 1039 | authToken: backendAuthToken, | |
| 1200 | host: backendBindHost, | |
| 1201 | desktopBootstrapToken: backendBootstrapToken, | |
| 1040 | 1202 | ...(backendObservabilitySettings.otlpTracesUrl |
| 1041 | 1203 | ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } |
| 1042 | 1204 | : {}), |
| ... | ... | @@ -1095,6 +1257,7 @@ function startBackend(): void { |
| 1095 | 1257 | } |
| 1096 | 1258 | |
| 1097 | 1259 | function stopBackend(): void { |
| 1260 | cancelBackendReadinessWait(); | |
| 1098 | 1261 | if (restartTimer) { |
| 1099 | 1262 | clearTimeout(restartTimer); |
| 1100 | 1263 | restartTimer = null; |
| ... | ... | @@ -1116,6 +1279,7 @@ function stopBackend(): void { |
| 1116 | 1279 | } |
| 1117 | 1280 | |
| 1118 | 1281 | async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { |
| 1282 | cancelBackendReadinessWait(); | |
| 1119 | 1283 | if (restartTimer) { |
| 1120 | 1284 | clearTimeout(restartTimer); |
| 1121 | 1285 | restartTimer = null; |
| ... | ... | @@ -1168,19 +1332,38 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { |
| 1168 | 1332 | } |
| 1169 | 1333 | |
| 1170 | 1334 | function registerIpcHandlers(): void { |
| 1171 | ipcMain.removeAllListeners(GET_WS_URL_CHANNEL); | |
| 1172 | ipcMain.on(GET_WS_URL_CHANNEL, (event) => { | |
| 1173 | event.returnValue = backendWsUrl; | |
| 1174 | }); | |
| 1175 | ||
| 1176 | 1335 | ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); |
| 1177 | 1336 | ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { |
| 1178 | 1337 | event.returnValue = { |
| 1179 | 1338 | label: "Local environment", |
| 1180 | wsUrl: backendWsUrl || null, | |
| 1339 | httpBaseUrl: backendHttpUrl || null, | |
| 1340 | wsBaseUrl: backendWsUrl || null, | |
| 1341 | bootstrapToken: backendBootstrapToken || undefined, | |
| 1181 | 1342 | } as const; |
| 1182 | 1343 | }); |
| 1183 | 1344 | |
| 1345 | ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); | |
| 1346 | ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); | |
| 1347 | ||
| 1348 | ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); | |
| 1349 | ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { | |
| 1350 | if (rawMode !== "local-only" && rawMode !== "network-accessible") { | |
| 1351 | throw new Error("Invalid desktop server exposure input."); | |
| 1352 | } | |
| 1353 | ||
| 1354 | const nextMode = rawMode as DesktopServerExposureMode; | |
| 1355 | if (nextMode === desktopServerExposureMode) { | |
| 1356 | return getDesktopServerExposureState(); | |
| 1357 | } | |
| 1358 | ||
| 1359 | const nextState = await applyDesktopServerExposureMode(nextMode, { | |
| 1360 | persist: true, | |
| 1361 | rejectIfUnavailable: true, | |
| 1362 | }); | |
| 1363 | relaunchDesktopApp(`serverExposureMode=${nextMode}`); | |
| 1364 | return nextState; | |
| 1365 | }); | |
| 1366 | ||
| 1184 | 1367 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); |
| 1185 | 1368 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { |
| 1186 | 1369 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; |
| ... | ... | @@ -1346,14 +1529,19 @@ function getIconOption(): { icon: string } | Record<string, never> { |
| 1346 | 1529 | return iconPath ? { icon: iconPath } : {}; |
| 1347 | 1530 | } |
| 1348 | 1531 | |
| 1532 | function getInitialWindowBackgroundColor(): string { | |
| 1533 | return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; | |
| 1534 | } | |
| 1535 | ||
| 1349 | 1536 | function createWindow(): BrowserWindow { |
| 1350 | 1537 | const window = new BrowserWindow({ |
| 1351 | 1538 | width: 1100, |
| 1352 | 1539 | height: 780, |
| 1353 | 1540 | minWidth: 840, |
| 1354 | 1541 | minHeight: 620, |
| 1355 | show: false, | |
| 1542 | show: isDevelopment, | |
| 1356 | 1543 | autoHideMenuBar: true, |
| 1544 | backgroundColor: getInitialWindowBackgroundColor(), | |
| 1357 | 1545 | ...getIconOption(), |
| 1358 | 1546 | title: APP_DISPLAY_NAME, |
| 1359 | 1547 | titleBarStyle: "hiddenInset", |
| ... | ... | @@ -1410,15 +1598,20 @@ function createWindow(): BrowserWindow { |
| 1410 | 1598 | window.setTitle(APP_DISPLAY_NAME); |
| 1411 | 1599 | emitUpdateState(); |
| 1412 | 1600 | }); |
| 1413 | window.once("ready-to-show", () => { | |
| 1414 | window.show(); | |
| 1415 | }); | |
| 1601 | if (!isDevelopment) { | |
| 1602 | window.once("ready-to-show", () => { | |
| 1603 | revealWindow(window); | |
| 1604 | }); | |
| 1605 | } | |
| 1416 | 1606 | |
| 1417 | 1607 | if (isDevelopment) { |
| 1418 | void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); | |
| 1608 | void window.loadURL(resolveDesktopDevServerUrl()); | |
| 1419 | 1609 | window.webContents.openDevTools({ mode: "detach" }); |
| 1610 | setImmediate(() => { | |
| 1611 | revealWindow(window); | |
| 1612 | }); | |
| 1420 | 1613 | } else { |
| 1421 | void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); | |
| 1614 | void window.loadURL(resolveDesktopWindowUrl()); | |
| 1422 | 1615 | } |
| 1423 | 1616 | |
| 1424 | 1617 | window.on("closed", () => { |
| ... | ... | @@ -1430,6 +1623,14 @@ function createWindow(): BrowserWindow { |
| 1430 | 1623 | return window; |
| 1431 | 1624 | } |
| 1432 | 1625 | |
| 1626 | function resolveDesktopWindowUrl(): string { | |
| 1627 | if (backendHttpUrl) { | |
| 1628 | return backendHttpUrl; | |
| 1629 | } | |
| 1630 | ||
| 1631 | return `${DESKTOP_SCHEME}://app`; | |
| 1632 | } | |
| 1633 | ||
| 1433 | 1634 | // Override Electron's userData path before the `ready` event so that |
| 1434 | 1635 | // Chromium session data uses a filesystem-friendly directory name. |
| 1435 | 1636 | // Must be called synchronously at the top level — before `app.whenReady()`. |
| ... | ... | @@ -1439,21 +1640,72 @@ configureAppIdentity(); |
| 1439 | 1640 | |
| 1440 | 1641 | async function bootstrap(): Promise<void> { |
| 1441 | 1642 | writeDesktopLogHeader("bootstrap start"); |
| 1442 | backendPort = await Effect.service(NetService).pipe( | |
| 1443 | Effect.flatMap((net) => net.reserveLoopbackPort()), | |
| 1444 | Effect.provide(NetService.layer), | |
| 1445 | Effect.runPromise, | |
| 1643 | const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); | |
| 1644 | if (isDevelopment && configuredBackendPort === undefined) { | |
| 1645 | throw new Error("T3CODE_PORT is required in desktop development."); | |
| 1646 | } | |
| 1647 | ||
| 1648 | backendPort = | |
| 1649 | configuredBackendPort ?? | |
| 1650 | (await Effect.service(NetService).pipe( | |
| 1651 | Effect.flatMap((net) => net.reserveLoopbackPort(DESKTOP_LOOPBACK_HOST)), | |
| 1652 | Effect.provide(NetService.layer), | |
| 1653 | Effect.runPromise, | |
| 1654 | )); | |
| 1655 | writeDesktopLogHeader( | |
| 1656 | configuredBackendPort === undefined | |
| 1657 | ? `reserved backend port via NetService port=${backendPort}` | |
| 1658 | : `using configured backend port port=${backendPort}`, | |
| 1446 | 1659 | ); |
| 1447 | writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); | |
| 1448 | backendAuthToken = Crypto.randomBytes(24).toString("hex"); | |
| 1449 | const baseUrl = `ws://127.0.0.1:${backendPort}`; | |
| 1450 | backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; | |
| 1451 | writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); | |
| 1660 | backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); | |
| 1661 | if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { | |
| 1662 | writeDesktopLogHeader( | |
| 1663 | `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, | |
| 1664 | ); | |
| 1665 | } | |
| 1666 | const serverExposureState = await applyDesktopServerExposureMode( | |
| 1667 | desktopSettings.serverExposureMode, | |
| 1668 | { | |
| 1669 | persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 1670 | }, | |
| 1671 | ); | |
| 1672 | writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); | |
| 1673 | if (serverExposureState.endpointUrl) { | |
| 1674 | writeDesktopLogHeader( | |
| 1675 | `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`, | |
| 1676 | ); | |
| 1677 | } else if (desktopSettings.serverExposureMode === "network-accessible") { | |
| 1678 | writeDesktopLogHeader( | |
| 1679 | "bootstrap fell back to local-only because no advertised network host was available", | |
| 1680 | ); | |
| 1681 | } | |
| 1452 | 1682 | |
| 1453 | 1683 | registerIpcHandlers(); |
| 1454 | 1684 | writeDesktopLogHeader("bootstrap ipc handlers registered"); |
| 1455 | 1685 | startBackend(); |
| 1456 | 1686 | writeDesktopLogHeader("bootstrap backend start requested"); |
| 1687 | ||
| 1688 | if (isDevelopment) { | |
| 1689 | mainWindow = createWindow(); | |
| 1690 | writeDesktopLogHeader("bootstrap main window created"); | |
| 1691 | void waitForBackendHttpReady(backendHttpUrl) | |
| 1692 | .then(() => { | |
| 1693 | writeDesktopLogHeader("bootstrap backend ready"); | |
| 1694 | }) | |
| 1695 | .catch((error) => { | |
| 1696 | if (isBackendReadinessAborted(error)) { | |
| 1697 | return; | |
| 1698 | } | |
| 1699 | writeDesktopLogHeader( | |
| 1700 | `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, | |
| 1701 | ); | |
| 1702 | console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); | |
| 1703 | }); | |
| 1704 | return; | |
| 1705 | } | |
| 1706 | ||
| 1707 | await waitForBackendHttpReady(backendHttpUrl); | |
| 1708 | writeDesktopLogHeader("bootstrap backend ready"); | |
| 1457 | 1709 | mainWindow = createWindow(); |
| 1458 | 1710 | writeDesktopLogHeader("bootstrap main window created"); |
| 1459 | 1711 | } |
| ... | ... | @@ -1463,6 +1715,7 @@ app.on("before-quit", () => { |
| 1463 | 1715 | updateInstallInFlight = false; |
| 1464 | 1716 | writeDesktopLogHeader("before-quit received"); |
| 1465 | 1717 | clearUpdatePollTimer(); |
| 1718 | cancelBackendReadinessWait(); | |
| 1466 | 1719 | stopBackend(); |
| 1467 | 1720 | restoreStdIoCapture?.(); |
| 1468 | 1721 | }); |
| ... | ... | @@ -1476,13 +1729,19 @@ app |
| 1476 | 1729 | registerDesktopProtocol(); |
| 1477 | 1730 | configureAutoUpdater(); |
| 1478 | 1731 | void bootstrap().catch((error) => { |
| 1732 | if (isBackendReadinessAborted(error) && isQuitting) { | |
| 1733 | return; | |
| 1734 | } | |
| 1479 | 1735 | handleFatalStartupError("bootstrap", error); |
| 1480 | 1736 | }); |
| 1481 | 1737 | |
| 1482 | 1738 | app.on("activate", () => { |
| 1483 | if (BrowserWindow.getAllWindows().length === 0) { | |
| 1484 | mainWindow = createWindow(); | |
| 1739 | const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 1740 | if (existingWindow) { | |
| 1741 | revealWindow(existingWindow); | |
| 1742 | return; | |
| 1485 | 1743 | } |
| 1744 | mainWindow = createWindow(); | |
| 1486 | 1745 | }); |
| 1487 | 1746 | }) |
| 1488 | 1747 | .catch((error) => { |
| ... | ... | @@ -1501,6 +1760,7 @@ if (process.platform !== "win32") { |
| 1501 | 1760 | isQuitting = true; |
| 1502 | 1761 | writeDesktopLogHeader("SIGINT received"); |
| 1503 | 1762 | clearUpdatePollTimer(); |
| 1763 | cancelBackendReadinessWait(); | |
| 1504 | 1764 | stopBackend(); |
| 1505 | 1765 | restoreStdIoCapture?.(); |
| 1506 | 1766 | app.quit(); |
apps/desktop/src/preload.ts+4-5| ... | ... | @@ -12,14 +12,11 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 12 | 12 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 13 | 13 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 14 | 14 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 15 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | |
| 16 | 15 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; |
| 16 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | |
| 17 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | |
| 17 | 18 | |
| 18 | 19 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 19 | getWsUrl: () => { | |
| 20 | const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); | |
| 21 | return typeof result === "string" ? result : null; | |
| 22 | }, | |
| 23 | 20 | getLocalEnvironmentBootstrap: () => { |
| 24 | 21 | const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); |
| 25 | 22 | if (typeof result !== "object" || result === null) { |
| ... | ... | @@ -27,6 +24,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 27 | 24 | } |
| 28 | 25 | return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>; |
| 29 | 26 | }, |
| 27 | getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), | |
| 28 | setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), | |
| 30 | 29 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), |
| 31 | 30 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), |
| 32 | 31 | setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), |
apps/desktop/src/serverExposure.test.ts created+139| ... | ... | @@ -0,0 +1,139 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure"; | |
| 4 | ||
| 5 | describe("resolveLanAdvertisedHost", () => { | |
| 6 | it("prefers an explicit host override", () => { | |
| 7 | expect( | |
| 8 | resolveLanAdvertisedHost( | |
| 9 | { | |
| 10 | en0: [ | |
| 11 | { | |
| 12 | address: "192.168.1.44", | |
| 13 | family: "IPv4", | |
| 14 | internal: false, | |
| 15 | netmask: "255.255.255.0", | |
| 16 | cidr: "192.168.1.44/24", | |
| 17 | mac: "00:00:00:00:00:00", | |
| 18 | }, | |
| 19 | ], | |
| 20 | }, | |
| 21 | "10.0.0.9", | |
| 22 | ), | |
| 23 | ).toBe("10.0.0.9"); | |
| 24 | }); | |
| 25 | ||
| 26 | it("returns the first usable non-internal IPv4 address", () => { | |
| 27 | expect( | |
| 28 | resolveLanAdvertisedHost( | |
| 29 | { | |
| 30 | lo0: [ | |
| 31 | { | |
| 32 | address: "127.0.0.1", | |
| 33 | family: "IPv4", | |
| 34 | internal: true, | |
| 35 | netmask: "255.0.0.0", | |
| 36 | cidr: "127.0.0.1/8", | |
| 37 | mac: "00:00:00:00:00:00", | |
| 38 | }, | |
| 39 | ], | |
| 40 | en0: [ | |
| 41 | { | |
| 42 | address: "192.168.1.44", | |
| 43 | family: "IPv4", | |
| 44 | internal: false, | |
| 45 | netmask: "255.255.255.0", | |
| 46 | cidr: "192.168.1.44/24", | |
| 47 | mac: "00:00:00:00:00:00", | |
| 48 | }, | |
| 49 | ], | |
| 50 | }, | |
| 51 | undefined, | |
| 52 | ), | |
| 53 | ).toBe("192.168.1.44"); | |
| 54 | }); | |
| 55 | ||
| 56 | it("returns null when no usable network address is available", () => { | |
| 57 | expect( | |
| 58 | resolveLanAdvertisedHost( | |
| 59 | { | |
| 60 | lo0: [ | |
| 61 | { | |
| 62 | address: "127.0.0.1", | |
| 63 | family: "IPv4", | |
| 64 | internal: true, | |
| 65 | netmask: "255.0.0.0", | |
| 66 | cidr: "127.0.0.1/8", | |
| 67 | mac: "00:00:00:00:00:00", | |
| 68 | }, | |
| 69 | ], | |
| 70 | }, | |
| 71 | undefined, | |
| 72 | ), | |
| 73 | ).toBeNull(); | |
| 74 | }); | |
| 75 | }); | |
| 76 | ||
| 77 | describe("resolveDesktopServerExposure", () => { | |
| 78 | it("keeps the desktop server loopback-only when local-only mode is selected", () => { | |
| 79 | expect( | |
| 80 | resolveDesktopServerExposure({ | |
| 81 | mode: "local-only", | |
| 82 | port: 3773, | |
| 83 | networkInterfaces: {}, | |
| 84 | }), | |
| 85 | ).toEqual({ | |
| 86 | mode: "local-only", | |
| 87 | bindHost: "127.0.0.1", | |
| 88 | localHttpUrl: "http://127.0.0.1:3773", | |
| 89 | localWsUrl: "ws://127.0.0.1:3773", | |
| 90 | endpointUrl: null, | |
| 91 | advertisedHost: null, | |
| 92 | }); | |
| 93 | }); | |
| 94 | ||
| 95 | it("binds to all interfaces in network-accessible mode", () => { | |
| 96 | expect( | |
| 97 | resolveDesktopServerExposure({ | |
| 98 | mode: "network-accessible", | |
| 99 | port: 3773, | |
| 100 | networkInterfaces: { | |
| 101 | en0: [ | |
| 102 | { | |
| 103 | address: "192.168.1.44", | |
| 104 | family: "IPv4", | |
| 105 | internal: false, | |
| 106 | netmask: "255.255.255.0", | |
| 107 | cidr: "192.168.1.44/24", | |
| 108 | mac: "00:00:00:00:00:00", | |
| 109 | }, | |
| 110 | ], | |
| 111 | }, | |
| 112 | }), | |
| 113 | ).toEqual({ | |
| 114 | mode: "network-accessible", | |
| 115 | bindHost: "0.0.0.0", | |
| 116 | localHttpUrl: "http://127.0.0.1:3773", | |
| 117 | localWsUrl: "ws://127.0.0.1:3773", | |
| 118 | endpointUrl: "http://192.168.1.44:3773", | |
| 119 | advertisedHost: "192.168.1.44", | |
| 120 | }); | |
| 121 | }); | |
| 122 | ||
| 123 | it("stays network-accessible even when no LAN address is currently detectable", () => { | |
| 124 | expect( | |
| 125 | resolveDesktopServerExposure({ | |
| 126 | mode: "network-accessible", | |
| 127 | port: 3773, | |
| 128 | networkInterfaces: {}, | |
| 129 | }), | |
| 130 | ).toEqual({ | |
| 131 | mode: "network-accessible", | |
| 132 | bindHost: "0.0.0.0", | |
| 133 | localHttpUrl: "http://127.0.0.1:3773", | |
| 134 | localWsUrl: "ws://127.0.0.1:3773", | |
| 135 | endpointUrl: null, | |
| 136 | advertisedHost: null, | |
| 137 | }); | |
| 138 | }); | |
| 139 | }); |
apps/desktop/src/serverExposure.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import type { NetworkInterfaceInfo } from "node:os"; | |
| 2 | import type { DesktopServerExposureMode } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | |
| 5 | const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; | |
| 6 | ||
| 7 | export interface DesktopServerExposure { | |
| 8 | readonly mode: DesktopServerExposureMode; | |
| 9 | readonly bindHost: string; | |
| 10 | readonly localHttpUrl: string; | |
| 11 | readonly localWsUrl: string; | |
| 12 | readonly endpointUrl: string | null; | |
| 13 | readonly advertisedHost: string | null; | |
| 14 | } | |
| 15 | ||
| 16 | const normalizeOptionalHost = (value: string | undefined): string | undefined => { | |
| 17 | const normalized = value?.trim(); | |
| 18 | return normalized && normalized.length > 0 ? normalized : undefined; | |
| 19 | }; | |
| 20 | ||
| 21 | const isUsableLanIpv4Address = (address: string): boolean => | |
| 22 | !address.startsWith("127.") && !address.startsWith("169.254."); | |
| 23 | ||
| 24 | export function resolveLanAdvertisedHost( | |
| 25 | networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>, | |
| 26 | explicitHost: string | undefined, | |
| 27 | ): string | null { | |
| 28 | const normalizedExplicitHost = normalizeOptionalHost(explicitHost); | |
| 29 | if (normalizedExplicitHost) { | |
| 30 | return normalizedExplicitHost; | |
| 31 | } | |
| 32 | ||
| 33 | for (const interfaceAddresses of Object.values(networkInterfaces)) { | |
| 34 | if (!interfaceAddresses) continue; | |
| 35 | ||
| 36 | for (const address of interfaceAddresses) { | |
| 37 | if (address.internal) continue; | |
| 38 | if (address.family !== "IPv4") continue; | |
| 39 | if (!isUsableLanIpv4Address(address.address)) continue; | |
| 40 | return address.address; | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | return null; | |
| 45 | } | |
| 46 | ||
| 47 | export function resolveDesktopServerExposure(input: { | |
| 48 | readonly mode: DesktopServerExposureMode; | |
| 49 | readonly port: number; | |
| 50 | readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>; | |
| 51 | readonly advertisedHostOverride?: string; | |
| 52 | }): DesktopServerExposure { | |
| 53 | const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; | |
| 54 | const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; | |
| 55 | ||
| 56 | if (input.mode === "local-only") { | |
| 57 | return { | |
| 58 | mode: input.mode, | |
| 59 | bindHost: DESKTOP_LOOPBACK_HOST, | |
| 60 | localHttpUrl, | |
| 61 | localWsUrl, | |
| 62 | endpointUrl: null, | |
| 63 | advertisedHost: null, | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | const advertisedHost = resolveLanAdvertisedHost( | |
| 68 | input.networkInterfaces, | |
| 69 | input.advertisedHostOverride, | |
| 70 | ); | |
| 71 | ||
| 72 | return { | |
| 73 | mode: input.mode, | |
| 74 | bindHost: DESKTOP_LAN_BIND_HOST, | |
| 75 | localHttpUrl, | |
| 76 | localWsUrl, | |
| 77 | endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, | |
| 78 | advertisedHost, | |
| 79 | }; | |
| 80 | } |
apps/server/src/auth/Layers/AuthControlPlane.test.ts created+111| ... | ... | @@ -0,0 +1,111 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import { ServerConfigShape } from "../../config.ts"; | |
| 6 | import { ServerConfig } from "../../config.ts"; | |
| 7 | import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; | |
| 8 | import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 9 | import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; | |
| 10 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 11 | import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; | |
| 12 | import { makeAuthControlPlane } from "./AuthControlPlane.ts"; | |
| 13 | import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 14 | ||
| 15 | const makeServerConfigLayer = ( | |
| 16 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 17 | ) => | |
| 18 | Layer.effect( | |
| 19 | ServerConfig, | |
| 20 | Effect.gen(function* () { | |
| 21 | const config = yield* ServerConfig; | |
| 22 | return { | |
| 23 | ...config, | |
| 24 | ...overrides, | |
| 25 | } satisfies ServerConfigShape; | |
| 26 | }), | |
| 27 | ).pipe( | |
| 28 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), | |
| 29 | ); | |
| 30 | ||
| 31 | const makeAuthControlPlaneLayer = ( | |
| 32 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 33 | ) => | |
| 34 | Layer.effect(AuthControlPlane, makeAuthControlPlane).pipe( | |
| 35 | Layer.provideMerge(BootstrapCredentialServiceLive), | |
| 36 | Layer.provideMerge(SessionCredentialServiceLive), | |
| 37 | Layer.provideMerge(ServerSecretStoreLive), | |
| 38 | Layer.provideMerge(SqlitePersistenceMemory), | |
| 39 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 40 | ); | |
| 41 | ||
| 42 | it.layer(NodeServices.layer)("AuthControlPlane", (it) => { | |
| 43 | it.effect("creates, lists, and revokes client pairing links", () => | |
| 44 | Effect.gen(function* () { | |
| 45 | const authControlPlane = yield* AuthControlPlane; | |
| 46 | ||
| 47 | const created = yield* authControlPlane.createPairingLink({ | |
| 48 | role: "client", | |
| 49 | subject: "one-time-token", | |
| 50 | label: "CI phone", | |
| 51 | }); | |
| 52 | const listedBeforeRevoke = yield* authControlPlane.listPairingLinks({ role: "client" }); | |
| 53 | const revoked = yield* authControlPlane.revokePairingLink(created.id); | |
| 54 | const listedAfterRevoke = yield* authControlPlane.listPairingLinks({ role: "client" }); | |
| 55 | ||
| 56 | expect(created.role).toBe("client"); | |
| 57 | expect(created.credential.length).toBeGreaterThan(0); | |
| 58 | expect(listedBeforeRevoke).toHaveLength(1); | |
| 59 | expect(listedBeforeRevoke[0]?.id).toBe(created.id); | |
| 60 | expect(listedBeforeRevoke[0]?.label).toBe("CI phone"); | |
| 61 | expect(listedBeforeRevoke[0]?.credential).toBe(created.credential); | |
| 62 | expect(revoked).toBe(true); | |
| 63 | expect(listedAfterRevoke).toHaveLength(0); | |
| 64 | }).pipe(Effect.provide(makeAuthControlPlaneLayer())), | |
| 65 | ); | |
| 66 | ||
| 67 | it.effect("issues bearer sessions and lists them without exposing raw tokens", () => | |
| 68 | Effect.gen(function* () { | |
| 69 | const authControlPlane = yield* AuthControlPlane; | |
| 70 | const sessionCredentials = yield* SessionCredentialService; | |
| 71 | ||
| 72 | const issued = yield* authControlPlane.issueSession({ | |
| 73 | label: "deploy-bot", | |
| 74 | }); | |
| 75 | const verified = yield* sessionCredentials.verify(issued.token); | |
| 76 | const listedBeforeRevoke = yield* authControlPlane.listSessions(); | |
| 77 | const revoked = yield* authControlPlane.revokeSession(issued.sessionId); | |
| 78 | const listedAfterRevoke = yield* authControlPlane.listSessions(); | |
| 79 | ||
| 80 | expect(issued.method).toBe("bearer-session-token"); | |
| 81 | expect(issued.role).toBe("owner"); | |
| 82 | expect(issued.client.deviceType).toBe("bot"); | |
| 83 | expect(issued.client.label).toBe("deploy-bot"); | |
| 84 | expect(verified.sessionId).toBe(issued.sessionId); | |
| 85 | expect(verified.role).toBe("owner"); | |
| 86 | expect(verified.method).toBe("bearer-session-token"); | |
| 87 | expect(listedBeforeRevoke).toHaveLength(1); | |
| 88 | expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); | |
| 89 | expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false); | |
| 90 | expect(revoked).toBe(true); | |
| 91 | expect(listedAfterRevoke).toHaveLength(0); | |
| 92 | }).pipe(Effect.provide(makeAuthControlPlaneLayer())), | |
| 93 | ); | |
| 94 | ||
| 95 | it.effect("surfaces lastConnectedAt through the listed session view", () => | |
| 96 | Effect.gen(function* () { | |
| 97 | const authControlPlane = yield* AuthControlPlane; | |
| 98 | const sessionCredentials = yield* SessionCredentialService; | |
| 99 | ||
| 100 | const issued = yield* authControlPlane.issueSession({ | |
| 101 | label: "remote-ipad", | |
| 102 | }); | |
| 103 | const beforeConnect = yield* authControlPlane.listSessions(); | |
| 104 | yield* sessionCredentials.markConnected(issued.sessionId); | |
| 105 | const afterConnect = yield* authControlPlane.listSessions(); | |
| 106 | ||
| 107 | expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); | |
| 108 | expect(afterConnect[0]?.lastConnectedAt).not.toBeNull(); | |
| 109 | }).pipe(Effect.provide(makeAuthControlPlaneLayer())), | |
| 110 | ); | |
| 111 | }); |
apps/server/src/auth/Layers/AuthControlPlane.ts created+176| ... | ... | @@ -0,0 +1,176 @@ |
| 1 | import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; | |
| 2 | import { DateTime, Effect, Layer } from "effect"; | |
| 3 | ||
| 4 | import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; | |
| 5 | import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 6 | import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; | |
| 7 | import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; | |
| 8 | import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 9 | import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Layers/Sqlite.ts"; | |
| 10 | import { | |
| 11 | AuthControlPlane, | |
| 12 | AuthControlPlaneError, | |
| 13 | AuthControlPlaneShape, | |
| 14 | DEFAULT_SESSION_SUBJECT, | |
| 15 | IssuedBearerSession, | |
| 16 | IssuedPairingLink, | |
| 17 | } from "../Services/AuthControlPlane.ts"; | |
| 18 | ||
| 19 | const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { | |
| 20 | if (left.role !== right.role) { | |
| 21 | return left.role === "owner" ? -1 : 1; | |
| 22 | } | |
| 23 | if (left.connected !== right.connected) { | |
| 24 | return left.connected ? -1 : 1; | |
| 25 | } | |
| 26 | return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; | |
| 27 | }; | |
| 28 | ||
| 29 | const toAuthControlPlaneError = | |
| 30 | (message: string) => | |
| 31 | (cause: unknown): AuthControlPlaneError => | |
| 32 | new AuthControlPlaneError({ | |
| 33 | message, | |
| 34 | cause, | |
| 35 | }); | |
| 36 | ||
| 37 | export const makeAuthControlPlane = Effect.gen(function* () { | |
| 38 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 39 | const sessions = yield* SessionCredentialService; | |
| 40 | ||
| 41 | const createPairingLink: AuthControlPlaneShape["createPairingLink"] = (input) => | |
| 42 | Effect.gen(function* () { | |
| 43 | const createdAt = yield* DateTime.now; | |
| 44 | const issued = yield* bootstrapCredentials.issueOneTimeToken({ | |
| 45 | role: input?.role ?? "client", | |
| 46 | subject: input?.subject ?? "one-time-token", | |
| 47 | ...(input?.ttl ? { ttl: input.ttl } : {}), | |
| 48 | ...(input?.label ? { label: input.label } : {}), | |
| 49 | }); | |
| 50 | return { | |
| 51 | id: issued.id, | |
| 52 | credential: issued.credential, | |
| 53 | role: input?.role ?? "client", | |
| 54 | subject: input?.subject ?? "one-time-token", | |
| 55 | ...(issued.label ? { label: issued.label } : {}), | |
| 56 | createdAt: DateTime.toUtc(createdAt), | |
| 57 | expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 58 | } satisfies IssuedPairingLink; | |
| 59 | }).pipe(Effect.mapError(toAuthControlPlaneError("Failed to create pairing link."))); | |
| 60 | ||
| 61 | const listPairingLinks: AuthControlPlaneShape["listPairingLinks"] = (input) => | |
| 62 | bootstrapCredentials.listActive().pipe( | |
| 63 | Effect.map((pairingLinks) => | |
| 64 | pairingLinks | |
| 65 | .filter((pairingLink) => (input?.role ? pairingLink.role === input.role : true)) | |
| 66 | .filter((pairingLink) => !input?.excludeSubjects?.includes(pairingLink.subject)) | |
| 67 | .map((pairingLink) => | |
| 68 | pairingLink.label | |
| 69 | ? ({ | |
| 70 | id: pairingLink.id, | |
| 71 | credential: pairingLink.credential, | |
| 72 | role: pairingLink.role, | |
| 73 | subject: pairingLink.subject, | |
| 74 | label: pairingLink.label, | |
| 75 | createdAt: pairingLink.createdAt, | |
| 76 | expiresAt: pairingLink.expiresAt, | |
| 77 | } satisfies AuthPairingLink) | |
| 78 | : ({ | |
| 79 | id: pairingLink.id, | |
| 80 | credential: pairingLink.credential, | |
| 81 | role: pairingLink.role, | |
| 82 | subject: pairingLink.subject, | |
| 83 | createdAt: pairingLink.createdAt, | |
| 84 | expiresAt: pairingLink.expiresAt, | |
| 85 | } satisfies AuthPairingLink), | |
| 86 | ) | |
| 87 | .toSorted( | |
| 88 | (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, | |
| 89 | ), | |
| 90 | ), | |
| 91 | Effect.mapError(toAuthControlPlaneError("Failed to list pairing links.")), | |
| 92 | ); | |
| 93 | ||
| 94 | const revokePairingLink: AuthControlPlaneShape["revokePairingLink"] = (id) => | |
| 95 | bootstrapCredentials | |
| 96 | .revoke(id) | |
| 97 | .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke pairing link."))); | |
| 98 | ||
| 99 | const issueSession: AuthControlPlaneShape["issueSession"] = (input) => | |
| 100 | sessions | |
| 101 | .issue({ | |
| 102 | subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, | |
| 103 | method: "bearer-session-token", | |
| 104 | role: input?.role ?? "owner", | |
| 105 | client: { | |
| 106 | ...(input?.label ? { label: input.label } : {}), | |
| 107 | deviceType: "bot", | |
| 108 | }, | |
| 109 | ...(input?.ttl ? { ttl: input.ttl } : {}), | |
| 110 | }) | |
| 111 | .pipe( | |
| 112 | Effect.flatMap((issued) => { | |
| 113 | if (issued.method !== "bearer-session-token") { | |
| 114 | return Effect.fail( | |
| 115 | new AuthControlPlaneError({ | |
| 116 | message: "CLI session issuance produced an unexpected session method.", | |
| 117 | }), | |
| 118 | ); | |
| 119 | } | |
| 120 | ||
| 121 | return Effect.succeed({ | |
| 122 | sessionId: issued.sessionId, | |
| 123 | token: issued.token, | |
| 124 | method: "bearer-session-token" as const, | |
| 125 | role: issued.role, | |
| 126 | subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, | |
| 127 | client: issued.client, | |
| 128 | expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 129 | } satisfies IssuedBearerSession); | |
| 130 | }), | |
| 131 | Effect.mapError(toAuthControlPlaneError("Failed to issue session token.")), | |
| 132 | ); | |
| 133 | ||
| 134 | const listSessions: AuthControlPlaneShape["listSessions"] = () => | |
| 135 | sessions.listActive().pipe( | |
| 136 | Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), | |
| 137 | Effect.mapError(toAuthControlPlaneError("Failed to list sessions.")), | |
| 138 | ); | |
| 139 | ||
| 140 | const revokeSession: AuthControlPlaneShape["revokeSession"] = (sessionId) => | |
| 141 | sessions | |
| 142 | .revoke(sessionId) | |
| 143 | .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session."))); | |
| 144 | ||
| 145 | const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = ( | |
| 146 | sessionId, | |
| 147 | ) => | |
| 148 | sessions | |
| 149 | .revokeAllExcept(sessionId) | |
| 150 | .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions."))); | |
| 151 | ||
| 152 | return { | |
| 153 | createPairingLink, | |
| 154 | listPairingLinks, | |
| 155 | revokePairingLink, | |
| 156 | issueSession, | |
| 157 | listSessions, | |
| 158 | revokeSession, | |
| 159 | revokeOtherSessionsExcept, | |
| 160 | } satisfies AuthControlPlaneShape; | |
| 161 | }); | |
| 162 | ||
| 163 | export const AuthCoreLive = Layer.mergeAll( | |
| 164 | BootstrapCredentialServiceLive, | |
| 165 | SessionCredentialServiceLive, | |
| 166 | ); | |
| 167 | ||
| 168 | export const AuthStorageLive = Layer.mergeAll(ServerSecretStoreLive, SqlitePersistenceLayerLive); | |
| 169 | ||
| 170 | export const AuthRuntimeLive = AuthCoreLive.pipe(Layer.provideMerge(AuthStorageLive)); | |
| 171 | ||
| 172 | export const AuthControlPlaneLive = Layer.effect(AuthControlPlane, makeAuthControlPlane); | |
| 173 | ||
| 174 | export const AuthControlPlaneRuntimeLive = AuthControlPlaneLive.pipe( | |
| 175 | Layer.provideMerge(AuthRuntimeLive), | |
| 176 | ); |
apps/server/src/auth/Layers/BootstrapCredentialService.test.ts created+151| ... | ... | @@ -0,0 +1,151 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { Duration, Effect, Layer } from "effect"; | |
| 4 | import { TestClock } from "effect/testing"; | |
| 5 | ||
| 6 | import type { ServerConfigShape } from "../../config.ts"; | |
| 7 | import { ServerConfig } from "../../config.ts"; | |
| 8 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 9 | import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; | |
| 10 | import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; | |
| 11 | ||
| 12 | const makeServerConfigLayer = ( | |
| 13 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 14 | ) => | |
| 15 | Layer.effect( | |
| 16 | ServerConfig, | |
| 17 | Effect.gen(function* () { | |
| 18 | const config = yield* ServerConfig; | |
| 19 | return { | |
| 20 | ...config, | |
| 21 | ...overrides, | |
| 22 | } satisfies ServerConfigShape; | |
| 23 | }), | |
| 24 | ).pipe( | |
| 25 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), | |
| 26 | ); | |
| 27 | ||
| 28 | const makeBootstrapCredentialLayer = ( | |
| 29 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 30 | ) => | |
| 31 | BootstrapCredentialServiceLive.pipe( | |
| 32 | Layer.provide(SqlitePersistenceMemory), | |
| 33 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 34 | ); | |
| 35 | ||
| 36 | it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { | |
| 37 | it.effect("issues pairing tokens in a short manual-entry format", () => | |
| 38 | Effect.gen(function* () { | |
| 39 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 40 | const issued = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 41 | ||
| 42 | expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/); | |
| 43 | }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 44 | ); | |
| 45 | ||
| 46 | it.effect("issues one-time bootstrap tokens that can only be consumed once", () => | |
| 47 | Effect.gen(function* () { | |
| 48 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 49 | const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" }); | |
| 50 | const first = yield* bootstrapCredentials.consume(issued.credential); | |
| 51 | const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); | |
| 52 | ||
| 53 | expect(first.method).toBe("one-time-token"); | |
| 54 | expect(first.role).toBe("client"); | |
| 55 | expect(first.subject).toBe("one-time-token"); | |
| 56 | expect(first.label).toBe("Julius iPhone"); | |
| 57 | expect(issued.label).toBe("Julius iPhone"); | |
| 58 | expect(second._tag).toBe("BootstrapCredentialError"); | |
| 59 | expect(second.message).toContain("Unknown bootstrap credential"); | |
| 60 | }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 61 | ); | |
| 62 | ||
| 63 | it.effect("atomically consumes a one-time token when multiple requests race", () => | |
| 64 | Effect.gen(function* () { | |
| 65 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 66 | const token = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 67 | const results = yield* Effect.all( | |
| 68 | Array.from({ length: 8 }, () => | |
| 69 | Effect.result(bootstrapCredentials.consume(token.credential)), | |
| 70 | ), | |
| 71 | { | |
| 72 | concurrency: "unbounded", | |
| 73 | }, | |
| 74 | ); | |
| 75 | ||
| 76 | const successes = results.filter((result) => result._tag === "Success"); | |
| 77 | const failures = results.filter((result) => result._tag === "Failure"); | |
| 78 | ||
| 79 | expect(successes).toHaveLength(1); | |
| 80 | expect(failures).toHaveLength(7); | |
| 81 | for (const failure of failures) { | |
| 82 | expect(failure.failure._tag).toBe("BootstrapCredentialError"); | |
| 83 | expect(failure.failure.message).toContain("Unknown bootstrap credential"); | |
| 84 | } | |
| 85 | }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 86 | ); | |
| 87 | ||
| 88 | it.effect("seeds the desktop bootstrap credential as a one-time grant", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 91 | const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); | |
| 92 | const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | |
| 93 | ||
| 94 | expect(first.method).toBe("desktop-bootstrap"); | |
| 95 | expect(first.role).toBe("owner"); | |
| 96 | expect(first.subject).toBe("desktop-bootstrap"); | |
| 97 | expect(second._tag).toBe("BootstrapCredentialError"); | |
| 98 | expect(second.status).toBe(401); | |
| 99 | }).pipe( | |
| 100 | Effect.provide( | |
| 101 | makeBootstrapCredentialLayer({ | |
| 102 | desktopBootstrapToken: "desktop-bootstrap-token", | |
| 103 | }), | |
| 104 | ), | |
| 105 | ), | |
| 106 | ); | |
| 107 | ||
| 108 | it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => | |
| 109 | Effect.gen(function* () { | |
| 110 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 111 | ||
| 112 | yield* TestClock.adjust(Duration.minutes(6)); | |
| 113 | const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | |
| 114 | ||
| 115 | expect(expired._tag).toBe("BootstrapCredentialError"); | |
| 116 | expect(expired.status).toBe(401); | |
| 117 | expect(expired.message).toContain("Bootstrap credential expired"); | |
| 118 | }).pipe( | |
| 119 | Effect.provide( | |
| 120 | Layer.merge( | |
| 121 | makeBootstrapCredentialLayer({ | |
| 122 | desktopBootstrapToken: "desktop-bootstrap-token", | |
| 123 | }), | |
| 124 | TestClock.layer(), | |
| 125 | ), | |
| 126 | ), | |
| 127 | ), | |
| 128 | ); | |
| 129 | ||
| 130 | it.effect("lists and revokes active pairing links", () => | |
| 131 | Effect.gen(function* () { | |
| 132 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 133 | const first = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 134 | const second = yield* bootstrapCredentials.issueOneTimeToken({ role: "owner" }); | |
| 135 | ||
| 136 | const activeBeforeRevoke = yield* bootstrapCredentials.listActive(); | |
| 137 | expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id); | |
| 138 | expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id); | |
| 139 | ||
| 140 | const revoked = yield* bootstrapCredentials.revoke(first.id); | |
| 141 | const activeAfterRevoke = yield* bootstrapCredentials.listActive(); | |
| 142 | const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential)); | |
| 143 | ||
| 144 | expect(revoked).toBe(true); | |
| 145 | expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); | |
| 146 | expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); | |
| 147 | expect(revokedConsume.message).toContain("no longer available"); | |
| 148 | expect(revokedConsume.status).toBe(401); | |
| 149 | }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 150 | ); | |
| 151 | }); |
apps/server/src/auth/Layers/BootstrapCredentialService.ts created+296| ... | ... | @@ -0,0 +1,296 @@ |
| 1 | import type { AuthPairingLink } from "@t3tools/contracts"; | |
| 2 | import { DateTime, Duration, Effect, Layer, PubSub, Ref, Stream } from "effect"; | |
| 3 | import { Option } from "effect"; | |
| 4 | ||
| 5 | import { ServerConfig } from "../../config.ts"; | |
| 6 | import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts"; | |
| 7 | import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts"; | |
| 8 | import { | |
| 9 | BootstrapCredentialError, | |
| 10 | BootstrapCredentialService, | |
| 11 | type BootstrapCredentialChange, | |
| 12 | type BootstrapCredentialServiceShape, | |
| 13 | type BootstrapGrant, | |
| 14 | type IssuedBootstrapCredential, | |
| 15 | } from "../Services/BootstrapCredentialService.ts"; | |
| 16 | ||
| 17 | interface StoredBootstrapGrant extends BootstrapGrant { | |
| 18 | readonly remainingUses: number | "unbounded"; | |
| 19 | } | |
| 20 | ||
| 21 | type ConsumeResult = | |
| 22 | | { | |
| 23 | readonly _tag: "error"; | |
| 24 | readonly reason: "not-found" | "expired"; | |
| 25 | readonly error: BootstrapCredentialError; | |
| 26 | } | |
| 27 | | { | |
| 28 | readonly _tag: "success"; | |
| 29 | readonly grant: BootstrapGrant; | |
| 30 | }; | |
| 31 | ||
| 32 | const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); | |
| 33 | const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; | |
| 34 | const PAIRING_TOKEN_LENGTH = 12; | |
| 35 | ||
| 36 | const generatePairingToken = (): string => { | |
| 37 | const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH)); | |
| 38 | ||
| 39 | return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join(""); | |
| 40 | }; | |
| 41 | ||
| 42 | export const makeBootstrapCredentialService = Effect.gen(function* () { | |
| 43 | const config = yield* ServerConfig; | |
| 44 | const pairingLinks = yield* AuthPairingLinkRepository; | |
| 45 | const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>()); | |
| 46 | const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>(); | |
| 47 | ||
| 48 | const invalidBootstrapCredentialError = (message: string) => | |
| 49 | new BootstrapCredentialError({ | |
| 50 | message, | |
| 51 | status: 401, | |
| 52 | }); | |
| 53 | ||
| 54 | const internalBootstrapCredentialError = (message: string, cause: unknown) => | |
| 55 | new BootstrapCredentialError({ | |
| 56 | message, | |
| 57 | status: 500, | |
| 58 | cause, | |
| 59 | }); | |
| 60 | ||
| 61 | const seedGrant = (credential: string, grant: StoredBootstrapGrant) => | |
| 62 | Ref.update(seededGrantsRef, (current) => { | |
| 63 | const next = new Map(current); | |
| 64 | next.set(credential, grant); | |
| 65 | return next; | |
| 66 | }); | |
| 67 | ||
| 68 | const emitUpsert = (pairingLink: AuthPairingLink) => | |
| 69 | PubSub.publish(changesPubSub, { | |
| 70 | type: "pairingLinkUpserted", | |
| 71 | pairingLink, | |
| 72 | }).pipe(Effect.asVoid); | |
| 73 | ||
| 74 | const emitRemoved = (id: string) => | |
| 75 | PubSub.publish(changesPubSub, { | |
| 76 | type: "pairingLinkRemoved", | |
| 77 | id, | |
| 78 | }).pipe(Effect.asVoid); | |
| 79 | ||
| 80 | if (config.desktopBootstrapToken) { | |
| 81 | const now = yield* DateTime.now; | |
| 82 | yield* seedGrant(config.desktopBootstrapToken, { | |
| 83 | method: "desktop-bootstrap", | |
| 84 | role: "owner", | |
| 85 | subject: "desktop-bootstrap", | |
| 86 | expiresAt: DateTime.add(now, { | |
| 87 | milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES), | |
| 88 | }), | |
| 89 | remainingUses: 1, | |
| 90 | }); | |
| 91 | } | |
| 92 | ||
| 93 | const toBootstrapCredentialError = (message: string) => (cause: unknown) => | |
| 94 | internalBootstrapCredentialError(message, cause); | |
| 95 | ||
| 96 | const listActive: BootstrapCredentialServiceShape["listActive"] = () => | |
| 97 | Effect.gen(function* () { | |
| 98 | const now = yield* DateTime.now; | |
| 99 | const rows = yield* pairingLinks.listActive({ now }); | |
| 100 | ||
| 101 | return rows.map((row) => | |
| 102 | row.label | |
| 103 | ? ({ | |
| 104 | id: row.id, | |
| 105 | credential: row.credential, | |
| 106 | role: row.role, | |
| 107 | subject: row.subject, | |
| 108 | label: row.label, | |
| 109 | createdAt: row.createdAt, | |
| 110 | expiresAt: row.expiresAt, | |
| 111 | } satisfies AuthPairingLink) | |
| 112 | : ({ | |
| 113 | id: row.id, | |
| 114 | credential: row.credential, | |
| 115 | role: row.role, | |
| 116 | subject: row.subject, | |
| 117 | createdAt: row.createdAt, | |
| 118 | expiresAt: row.expiresAt, | |
| 119 | } satisfies AuthPairingLink), | |
| 120 | ); | |
| 121 | }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links."))); | |
| 122 | ||
| 123 | const revoke: BootstrapCredentialServiceShape["revoke"] = (id) => | |
| 124 | Effect.gen(function* () { | |
| 125 | const revokedAt = yield* DateTime.now; | |
| 126 | const revoked = yield* pairingLinks.revoke({ | |
| 127 | id, | |
| 128 | revokedAt, | |
| 129 | }); | |
| 130 | if (revoked) { | |
| 131 | yield* emitRemoved(id); | |
| 132 | } | |
| 133 | return revoked; | |
| 134 | }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link."))); | |
| 135 | ||
| 136 | const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) => | |
| 137 | Effect.gen(function* () { | |
| 138 | const id = crypto.randomUUID(); | |
| 139 | const credential = generatePairingToken(); | |
| 140 | const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; | |
| 141 | const now = yield* DateTime.now; | |
| 142 | const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); | |
| 143 | const issued: IssuedBootstrapCredential = { | |
| 144 | id, | |
| 145 | credential, | |
| 146 | ...(input?.label ? { label: input.label } : {}), | |
| 147 | expiresAt, | |
| 148 | }; | |
| 149 | yield* pairingLinks.create({ | |
| 150 | id, | |
| 151 | credential, | |
| 152 | method: "one-time-token", | |
| 153 | role: input?.role ?? "client", | |
| 154 | subject: input?.subject ?? "one-time-token", | |
| 155 | label: input?.label ?? null, | |
| 156 | createdAt: now, | |
| 157 | expiresAt: expiresAt, | |
| 158 | }); | |
| 159 | yield* emitUpsert({ | |
| 160 | id, | |
| 161 | credential, | |
| 162 | role: input?.role ?? "client", | |
| 163 | subject: input?.subject ?? "one-time-token", | |
| 164 | ...(input?.label ? { label: input.label } : {}), | |
| 165 | createdAt: now, | |
| 166 | expiresAt, | |
| 167 | }); | |
| 168 | return issued; | |
| 169 | }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential."))); | |
| 170 | ||
| 171 | const consume: BootstrapCredentialServiceShape["consume"] = (credential) => | |
| 172 | Effect.gen(function* () { | |
| 173 | const now = yield* DateTime.now; | |
| 174 | const seededResult: ConsumeResult = yield* Ref.modify( | |
| 175 | seededGrantsRef, | |
| 176 | (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => { | |
| 177 | const grant = current.get(credential); | |
| 178 | if (!grant) { | |
| 179 | return [ | |
| 180 | { | |
| 181 | _tag: "error", | |
| 182 | reason: "not-found", | |
| 183 | error: invalidBootstrapCredentialError("Unknown bootstrap credential."), | |
| 184 | }, | |
| 185 | current, | |
| 186 | ]; | |
| 187 | } | |
| 188 | ||
| 189 | const next = new Map(current); | |
| 190 | if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { | |
| 191 | next.delete(credential); | |
| 192 | return [ | |
| 193 | { | |
| 194 | _tag: "error", | |
| 195 | reason: "expired", | |
| 196 | error: invalidBootstrapCredentialError("Bootstrap credential expired."), | |
| 197 | }, | |
| 198 | next, | |
| 199 | ]; | |
| 200 | } | |
| 201 | ||
| 202 | const remainingUses = grant.remainingUses; | |
| 203 | if (typeof remainingUses === "number") { | |
| 204 | if (remainingUses <= 1) { | |
| 205 | next.delete(credential); | |
| 206 | } else { | |
| 207 | next.set(credential, { | |
| 208 | ...grant, | |
| 209 | remainingUses: remainingUses - 1, | |
| 210 | }); | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | return [ | |
| 215 | { | |
| 216 | _tag: "success", | |
| 217 | grant: { | |
| 218 | method: grant.method, | |
| 219 | role: grant.role, | |
| 220 | subject: grant.subject, | |
| 221 | ...(grant.label ? { label: grant.label } : {}), | |
| 222 | expiresAt: grant.expiresAt, | |
| 223 | } satisfies BootstrapGrant, | |
| 224 | }, | |
| 225 | next, | |
| 226 | ]; | |
| 227 | }, | |
| 228 | ); | |
| 229 | ||
| 230 | if (seededResult._tag === "success") { | |
| 231 | return seededResult.grant; | |
| 232 | } | |
| 233 | if (seededResult.reason !== "not-found") { | |
| 234 | return yield* seededResult.error; | |
| 235 | } | |
| 236 | ||
| 237 | const consumed = yield* pairingLinks.consumeAvailable({ | |
| 238 | credential, | |
| 239 | consumedAt: now, | |
| 240 | now, | |
| 241 | }); | |
| 242 | ||
| 243 | if (Option.isSome(consumed)) { | |
| 244 | yield* emitRemoved(consumed.value.id); | |
| 245 | return { | |
| 246 | method: consumed.value.method, | |
| 247 | role: consumed.value.role, | |
| 248 | subject: consumed.value.subject, | |
| 249 | ...(consumed.value.label ? { label: consumed.value.label } : {}), | |
| 250 | expiresAt: consumed.value.expiresAt, | |
| 251 | } satisfies BootstrapGrant; | |
| 252 | } | |
| 253 | ||
| 254 | const matching = yield* pairingLinks.getByCredential({ credential }); | |
| 255 | if (Option.isNone(matching)) { | |
| 256 | return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); | |
| 257 | } | |
| 258 | ||
| 259 | if (matching.value.revokedAt !== null) { | |
| 260 | return yield* invalidBootstrapCredentialError( | |
| 261 | "Bootstrap credential is no longer available.", | |
| 262 | ); | |
| 263 | } | |
| 264 | ||
| 265 | if (matching.value.consumedAt !== null) { | |
| 266 | return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); | |
| 267 | } | |
| 268 | ||
| 269 | if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { | |
| 270 | return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); | |
| 271 | } | |
| 272 | ||
| 273 | return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); | |
| 274 | }).pipe( | |
| 275 | Effect.mapError((cause) => | |
| 276 | cause instanceof BootstrapCredentialError | |
| 277 | ? cause | |
| 278 | : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), | |
| 279 | ), | |
| 280 | ); | |
| 281 | ||
| 282 | return { | |
| 283 | issueOneTimeToken, | |
| 284 | listActive, | |
| 285 | get streamChanges() { | |
| 286 | return Stream.fromPubSub(changesPubSub); | |
| 287 | }, | |
| 288 | revoke, | |
| 289 | consume, | |
| 290 | } satisfies BootstrapCredentialServiceShape; | |
| 291 | }); | |
| 292 | ||
| 293 | export const BootstrapCredentialServiceLive = Layer.effect( | |
| 294 | BootstrapCredentialService, | |
| 295 | makeBootstrapCredentialService, | |
| 296 | ).pipe(Layer.provideMerge(AuthPairingLinkRepositoryLive)); |
apps/server/src/auth/Layers/ServerAuth.test.ts created+182| ... | ... | @@ -0,0 +1,182 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import type { ServerConfigShape } from "../../config.ts"; | |
| 6 | import { ServerConfig } from "../../config.ts"; | |
| 7 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 8 | import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; | |
| 9 | import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts"; | |
| 10 | import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts"; | |
| 11 | import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 12 | ||
| 13 | const makeServerConfigLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 14 | Layer.effect( | |
| 15 | ServerConfig, | |
| 16 | Effect.gen(function* () { | |
| 17 | const config = yield* ServerConfig; | |
| 18 | return { | |
| 19 | ...config, | |
| 20 | ...overrides, | |
| 21 | } satisfies ServerConfigShape; | |
| 22 | }), | |
| 23 | ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); | |
| 24 | ||
| 25 | const makeServerAuthLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 26 | ServerAuthLive.pipe( | |
| 27 | Layer.provide(SqlitePersistenceMemory), | |
| 28 | Layer.provide(ServerSecretStoreLive), | |
| 29 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 30 | ); | |
| 31 | ||
| 32 | const makeCookieRequest = ( | |
| 33 | sessionToken: string, | |
| 34 | ): Parameters<ServerAuthShape["authenticateHttpRequest"]>[0] => | |
| 35 | ({ | |
| 36 | cookies: { | |
| 37 | t3_session: sessionToken, | |
| 38 | }, | |
| 39 | headers: {}, | |
| 40 | }) as unknown as Parameters<ServerAuthShape["authenticateHttpRequest"]>[0]; | |
| 41 | ||
| 42 | const requestMetadata = { | |
| 43 | deviceType: "desktop" as const, | |
| 44 | os: "macOS", | |
| 45 | browser: "Chrome", | |
| 46 | ipAddress: "192.168.1.23", | |
| 47 | }; | |
| 48 | ||
| 49 | it.layer(NodeServices.layer)("ServerAuthLive", (it) => { | |
| 50 | it.effect("maps invalid bootstrap credential failures to 401", () => | |
| 51 | Effect.sync(() => { | |
| 52 | const error = toBootstrapExchangeAuthError( | |
| 53 | new BootstrapCredentialError({ | |
| 54 | message: "Unknown bootstrap credential.", | |
| 55 | status: 401, | |
| 56 | }), | |
| 57 | ); | |
| 58 | ||
| 59 | expect(error.status).toBe(401); | |
| 60 | expect(error.message).toBe("Invalid bootstrap credential."); | |
| 61 | }), | |
| 62 | ); | |
| 63 | ||
| 64 | it.effect("maps unexpected bootstrap failures to 500", () => | |
| 65 | Effect.sync(() => { | |
| 66 | const error = toBootstrapExchangeAuthError( | |
| 67 | new BootstrapCredentialError({ | |
| 68 | message: "Failed to consume bootstrap credential.", | |
| 69 | status: 500, | |
| 70 | cause: new Error("sqlite is unavailable"), | |
| 71 | }), | |
| 72 | ); | |
| 73 | ||
| 74 | expect(error.status).toBe(500); | |
| 75 | expect(error.message).toBe("Failed to validate bootstrap credential."); | |
| 76 | }), | |
| 77 | ); | |
| 78 | ||
| 79 | it.effect("issues client pairing credentials by default", () => | |
| 80 | Effect.gen(function* () { | |
| 81 | const serverAuth = yield* ServerAuth; | |
| 82 | ||
| 83 | const pairingCredential = yield* serverAuth.issuePairingCredential(); | |
| 84 | const exchanged = yield* serverAuth.exchangeBootstrapCredential( | |
| 85 | pairingCredential.credential, | |
| 86 | requestMetadata, | |
| 87 | ); | |
| 88 | const verified = yield* serverAuth.authenticateHttpRequest( | |
| 89 | makeCookieRequest(exchanged.sessionToken), | |
| 90 | ); | |
| 91 | ||
| 92 | expect(verified.sessionId.length).toBeGreaterThan(0); | |
| 93 | expect(verified.role).toBe("client"); | |
| 94 | expect(verified.subject).toBe("one-time-token"); | |
| 95 | }).pipe(Effect.provide(makeServerAuthLayer())), | |
| 96 | ); | |
| 97 | ||
| 98 | it.effect("issues startup pairing URLs that bootstrap owner sessions", () => | |
| 99 | Effect.gen(function* () { | |
| 100 | const serverAuth = yield* ServerAuth; | |
| 101 | ||
| 102 | const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); | |
| 103 | const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token"); | |
| 104 | const listedPairingLinks = yield* serverAuth.listPairingLinks(); | |
| 105 | expect(token).toBeTruthy(); | |
| 106 | expect( | |
| 107 | listedPairingLinks.some((pairingLink) => pairingLink.subject === "owner-bootstrap"), | |
| 108 | ).toBe(false); | |
| 109 | ||
| 110 | const exchanged = yield* serverAuth.exchangeBootstrapCredential(token ?? "", requestMetadata); | |
| 111 | const verified = yield* serverAuth.authenticateHttpRequest( | |
| 112 | makeCookieRequest(exchanged.sessionToken), | |
| 113 | ); | |
| 114 | ||
| 115 | expect(verified.role).toBe("owner"); | |
| 116 | expect(verified.subject).toBe("owner-bootstrap"); | |
| 117 | }).pipe(Effect.provide(makeServerAuthLayer())), | |
| 118 | ); | |
| 119 | ||
| 120 | it.effect("lists pairing links and revokes other client sessions while keeping the owner", () => | |
| 121 | Effect.gen(function* () { | |
| 122 | const serverAuth = yield* ServerAuth; | |
| 123 | ||
| 124 | const ownerExchange = yield* serverAuth.exchangeBootstrapCredential( | |
| 125 | "desktop-bootstrap-token", | |
| 126 | requestMetadata, | |
| 127 | ); | |
| 128 | const ownerSession = yield* serverAuth.authenticateHttpRequest( | |
| 129 | makeCookieRequest(ownerExchange.sessionToken), | |
| 130 | ); | |
| 131 | const pairingCredential = yield* serverAuth.issuePairingCredential({ | |
| 132 | label: "Julius iPhone", | |
| 133 | }); | |
| 134 | const listedPairingLinks = yield* serverAuth.listPairingLinks(); | |
| 135 | const clientExchange = yield* serverAuth.exchangeBootstrapCredential( | |
| 136 | pairingCredential.credential, | |
| 137 | { | |
| 138 | ...requestMetadata, | |
| 139 | deviceType: "mobile", | |
| 140 | os: "iOS", | |
| 141 | browser: "Safari", | |
| 142 | ipAddress: "192.168.1.88", | |
| 143 | }, | |
| 144 | ); | |
| 145 | const clientSession = yield* serverAuth.authenticateHttpRequest( | |
| 146 | makeCookieRequest(clientExchange.sessionToken), | |
| 147 | ); | |
| 148 | const clientsBeforeRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); | |
| 149 | const revokedCount = yield* serverAuth.revokeOtherClientSessions(ownerSession.sessionId); | |
| 150 | const clientsAfterRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); | |
| 151 | ||
| 152 | expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id); | |
| 153 | expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe( | |
| 154 | "Julius iPhone", | |
| 155 | ); | |
| 156 | expect(clientsBeforeRevoke).toHaveLength(2); | |
| 157 | expect( | |
| 158 | clientsBeforeRevoke.find((entry) => entry.sessionId === ownerSession.sessionId)?.current, | |
| 159 | ).toBe(true); | |
| 160 | expect( | |
| 161 | clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current, | |
| 162 | ).toBe(false); | |
| 163 | expect( | |
| 164 | clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client | |
| 165 | .label, | |
| 166 | ).toBe("Julius iPhone"); | |
| 167 | expect( | |
| 168 | clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client | |
| 169 | .deviceType, | |
| 170 | ).toBe("mobile"); | |
| 171 | expect(revokedCount).toBe(1); | |
| 172 | expect(clientsAfterRevoke).toHaveLength(1); | |
| 173 | expect(clientsAfterRevoke[0]?.sessionId).toBe(ownerSession.sessionId); | |
| 174 | }).pipe( | |
| 175 | Effect.provide( | |
| 176 | makeServerAuthLayer({ | |
| 177 | desktopBootstrapToken: "desktop-bootstrap-token", | |
| 178 | }), | |
| 179 | ), | |
| 180 | ), | |
| 181 | ); | |
| 182 | }); |
apps/server/src/auth/Layers/ServerAuth.ts created+385| ... | ... | @@ -0,0 +1,385 @@ |
| 1 | import { | |
| 2 | type AuthBearerBootstrapResult, | |
| 3 | type AuthClientSession, | |
| 4 | type AuthBootstrapResult, | |
| 5 | type AuthPairingCredentialResult, | |
| 6 | type AuthSessionState, | |
| 7 | type AuthWebSocketTokenResult, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { DateTime, Effect, Layer, Option } from "effect"; | |
| 10 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | |
| 11 | ||
| 12 | import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; | |
| 13 | import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; | |
| 14 | import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; | |
| 15 | import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; | |
| 16 | import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; | |
| 17 | import { | |
| 18 | ServerAuth, | |
| 19 | type AuthenticatedSession, | |
| 20 | AuthError, | |
| 21 | type ServerAuthShape, | |
| 22 | } from "../Services/ServerAuth.ts"; | |
| 23 | import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 24 | import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; | |
| 25 | ||
| 26 | type BootstrapExchangeResult = { | |
| 27 | readonly response: AuthBootstrapResult; | |
| 28 | readonly sessionToken: string; | |
| 29 | }; | |
| 30 | ||
| 31 | const AUTHORIZATION_PREFIX = "Bearer "; | |
| 32 | const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken"; | |
| 33 | ||
| 34 | export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError { | |
| 35 | if (cause.status === 500) { | |
| 36 | return new AuthError({ | |
| 37 | message: "Failed to validate bootstrap credential.", | |
| 38 | status: 500, | |
| 39 | cause, | |
| 40 | }); | |
| 41 | } | |
| 42 | ||
| 43 | return new AuthError({ | |
| 44 | message: "Invalid bootstrap credential.", | |
| 45 | status: 401, | |
| 46 | cause, | |
| 47 | }); | |
| 48 | } | |
| 49 | ||
| 50 | function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { | |
| 51 | const header = request.headers["authorization"]; | |
| 52 | if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { | |
| 53 | return null; | |
| 54 | } | |
| 55 | const token = header.slice(AUTHORIZATION_PREFIX.length).trim(); | |
| 56 | return token.length > 0 ? token : null; | |
| 57 | } | |
| 58 | ||
| 59 | export const makeServerAuth = Effect.gen(function* () { | |
| 60 | const policy = yield* ServerAuthPolicy; | |
| 61 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 62 | const authControlPlane = yield* AuthControlPlane; | |
| 63 | const sessions = yield* SessionCredentialService; | |
| 64 | const descriptor = yield* policy.getDescriptor(); | |
| 65 | ||
| 66 | const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> => | |
| 67 | sessions.verify(token).pipe( | |
| 68 | Effect.map((session) => ({ | |
| 69 | sessionId: session.sessionId, | |
| 70 | subject: session.subject, | |
| 71 | method: session.method, | |
| 72 | role: session.role, | |
| 73 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | |
| 74 | })), | |
| 75 | Effect.mapError( | |
| 76 | (cause) => | |
| 77 | new AuthError({ | |
| 78 | message: "Unauthorized request.", | |
| 79 | status: 401, | |
| 80 | cause, | |
| 81 | }), | |
| 82 | ), | |
| 83 | ); | |
| 84 | ||
| 85 | const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { | |
| 86 | const cookieToken = request.cookies[sessions.cookieName]; | |
| 87 | const bearerToken = parseBearerToken(request); | |
| 88 | const credential = cookieToken ?? bearerToken; | |
| 89 | if (!credential) { | |
| 90 | return Effect.fail( | |
| 91 | new AuthError({ | |
| 92 | message: "Authentication required.", | |
| 93 | status: 401, | |
| 94 | }), | |
| 95 | ); | |
| 96 | } | |
| 97 | return authenticateToken(credential); | |
| 98 | }; | |
| 99 | ||
| 100 | const getSessionState: ServerAuthShape["getSessionState"] = (request) => | |
| 101 | authenticateRequest(request).pipe( | |
| 102 | Effect.map( | |
| 103 | (session) => | |
| 104 | ({ | |
| 105 | authenticated: true, | |
| 106 | auth: descriptor, | |
| 107 | role: session.role, | |
| 108 | sessionMethod: session.method, | |
| 109 | ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), | |
| 110 | }) satisfies AuthSessionState, | |
| 111 | ), | |
| 112 | Effect.catchTag("AuthError", () => | |
| 113 | Effect.succeed({ | |
| 114 | authenticated: false, | |
| 115 | auth: descriptor, | |
| 116 | } satisfies AuthSessionState), | |
| 117 | ), | |
| 118 | ); | |
| 119 | ||
| 120 | const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = ( | |
| 121 | credential, | |
| 122 | requestMetadata, | |
| 123 | ) => | |
| 124 | bootstrapCredentials.consume(credential).pipe( | |
| 125 | Effect.mapError(toBootstrapExchangeAuthError), | |
| 126 | Effect.flatMap((grant) => | |
| 127 | sessions | |
| 128 | .issue({ | |
| 129 | method: "browser-session-cookie", | |
| 130 | subject: grant.subject, | |
| 131 | role: grant.role, | |
| 132 | client: { | |
| 133 | ...requestMetadata, | |
| 134 | ...(grant.label ? { label: grant.label } : {}), | |
| 135 | }, | |
| 136 | }) | |
| 137 | .pipe( | |
| 138 | Effect.mapError( | |
| 139 | (cause) => | |
| 140 | new AuthError({ | |
| 141 | message: "Failed to issue authenticated session.", | |
| 142 | cause, | |
| 143 | }), | |
| 144 | ), | |
| 145 | ), | |
| 146 | ), | |
| 147 | Effect.map( | |
| 148 | (session) => | |
| 149 | ({ | |
| 150 | response: { | |
| 151 | authenticated: true, | |
| 152 | role: session.role, | |
| 153 | sessionMethod: session.method, | |
| 154 | expiresAt: DateTime.toUtc(session.expiresAt), | |
| 155 | } satisfies AuthBootstrapResult, | |
| 156 | sessionToken: session.token, | |
| 157 | }) satisfies BootstrapExchangeResult, | |
| 158 | ), | |
| 159 | ); | |
| 160 | ||
| 161 | const exchangeBootstrapCredentialForBearerSession: ServerAuthShape["exchangeBootstrapCredentialForBearerSession"] = | |
| 162 | (credential, requestMetadata) => | |
| 163 | bootstrapCredentials.consume(credential).pipe( | |
| 164 | Effect.mapError(toBootstrapExchangeAuthError), | |
| 165 | Effect.flatMap((grant) => | |
| 166 | sessions | |
| 167 | .issue({ | |
| 168 | method: "bearer-session-token", | |
| 169 | subject: grant.subject, | |
| 170 | role: grant.role, | |
| 171 | client: { | |
| 172 | ...requestMetadata, | |
| 173 | ...(grant.label ? { label: grant.label } : {}), | |
| 174 | }, | |
| 175 | }) | |
| 176 | .pipe( | |
| 177 | Effect.mapError( | |
| 178 | (cause) => | |
| 179 | new AuthError({ | |
| 180 | message: "Failed to issue authenticated session.", | |
| 181 | cause, | |
| 182 | }), | |
| 183 | ), | |
| 184 | ), | |
| 185 | ), | |
| 186 | Effect.map( | |
| 187 | (session) => | |
| 188 | ({ | |
| 189 | authenticated: true, | |
| 190 | role: session.role, | |
| 191 | sessionMethod: "bearer-session-token", | |
| 192 | expiresAt: DateTime.toUtc(session.expiresAt), | |
| 193 | sessionToken: session.token, | |
| 194 | }) satisfies AuthBearerBootstrapResult, | |
| 195 | ), | |
| 196 | ); | |
| 197 | ||
| 198 | const issuePairingCredential: ServerAuthShape["issuePairingCredential"] = (input) => | |
| 199 | authControlPlane | |
| 200 | .createPairingLink({ | |
| 201 | role: input?.role ?? "client", | |
| 202 | subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token", | |
| 203 | ...(input?.label ? { label: input.label } : {}), | |
| 204 | }) | |
| 205 | .pipe( | |
| 206 | Effect.mapError( | |
| 207 | (cause) => | |
| 208 | new AuthError({ | |
| 209 | message: "Failed to issue pairing credential.", | |
| 210 | cause, | |
| 211 | }), | |
| 212 | ), | |
| 213 | Effect.map( | |
| 214 | (issued) => | |
| 215 | ({ | |
| 216 | id: issued.id, | |
| 217 | credential: issued.credential, | |
| 218 | ...(issued.label ? { label: issued.label } : {}), | |
| 219 | expiresAt: issued.expiresAt, | |
| 220 | }) satisfies AuthPairingCredentialResult, | |
| 221 | ), | |
| 222 | ); | |
| 223 | ||
| 224 | const listPairingLinks: ServerAuthShape["listPairingLinks"] = () => | |
| 225 | authControlPlane | |
| 226 | .listPairingLinks({ | |
| 227 | role: "client", | |
| 228 | excludeSubjects: ["owner-bootstrap"], | |
| 229 | }) | |
| 230 | .pipe( | |
| 231 | Effect.mapError( | |
| 232 | (cause) => | |
| 233 | new AuthError({ | |
| 234 | message: "Failed to load pairing links.", | |
| 235 | cause, | |
| 236 | }), | |
| 237 | ), | |
| 238 | ); | |
| 239 | ||
| 240 | const revokePairingLink: ServerAuthShape["revokePairingLink"] = (id) => | |
| 241 | authControlPlane.revokePairingLink(id).pipe( | |
| 242 | Effect.mapError( | |
| 243 | (cause) => | |
| 244 | new AuthError({ | |
| 245 | message: "Failed to revoke pairing link.", | |
| 246 | cause, | |
| 247 | }), | |
| 248 | ), | |
| 249 | ); | |
| 250 | ||
| 251 | const listClientSessions: ServerAuthShape["listClientSessions"] = (currentSessionId) => | |
| 252 | authControlPlane.listSessions().pipe( | |
| 253 | Effect.mapError( | |
| 254 | (cause) => | |
| 255 | new AuthError({ | |
| 256 | message: "Failed to load paired clients.", | |
| 257 | cause, | |
| 258 | }), | |
| 259 | ), | |
| 260 | Effect.map((clientSessions) => | |
| 261 | clientSessions.map( | |
| 262 | (clientSession): AuthClientSession => ({ | |
| 263 | ...clientSession, | |
| 264 | current: clientSession.sessionId === currentSessionId, | |
| 265 | }), | |
| 266 | ), | |
| 267 | ), | |
| 268 | ); | |
| 269 | ||
| 270 | const revokeClientSession: ServerAuthShape["revokeClientSession"] = ( | |
| 271 | currentSessionId, | |
| 272 | targetSessionId, | |
| 273 | ) => | |
| 274 | Effect.gen(function* () { | |
| 275 | if (currentSessionId === targetSessionId) { | |
| 276 | return yield* new AuthError({ | |
| 277 | message: "Use revoke other clients to keep the current owner session active.", | |
| 278 | status: 403, | |
| 279 | }); | |
| 280 | } | |
| 281 | return yield* authControlPlane.revokeSession(targetSessionId).pipe( | |
| 282 | Effect.mapError( | |
| 283 | (cause) => | |
| 284 | new AuthError({ | |
| 285 | message: "Failed to revoke client session.", | |
| 286 | cause, | |
| 287 | }), | |
| 288 | ), | |
| 289 | ); | |
| 290 | }); | |
| 291 | ||
| 292 | const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = ( | |
| 293 | currentSessionId, | |
| 294 | ) => | |
| 295 | authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe( | |
| 296 | Effect.mapError( | |
| 297 | (cause) => | |
| 298 | new AuthError({ | |
| 299 | message: "Failed to revoke other client sessions.", | |
| 300 | cause, | |
| 301 | }), | |
| 302 | ), | |
| 303 | ); | |
| 304 | ||
| 305 | const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) => | |
| 306 | issuePairingCredential({ role: "owner" }).pipe( | |
| 307 | Effect.map((issued) => { | |
| 308 | const url = new URL(baseUrl); | |
| 309 | url.pathname = "/pair"; | |
| 310 | url.searchParams.delete("token"); | |
| 311 | url.hash = new URLSearchParams([["token", issued.credential]]).toString(); | |
| 312 | return url.toString(); | |
| 313 | }), | |
| 314 | ); | |
| 315 | ||
| 316 | const issueWebSocketToken: ServerAuthShape["issueWebSocketToken"] = (session) => | |
| 317 | sessions.issueWebSocketToken(session.sessionId).pipe( | |
| 318 | Effect.mapError( | |
| 319 | (cause) => | |
| 320 | new AuthError({ | |
| 321 | message: "Failed to issue websocket token.", | |
| 322 | cause, | |
| 323 | }), | |
| 324 | ), | |
| 325 | Effect.map( | |
| 326 | (issued) => | |
| 327 | ({ | |
| 328 | token: issued.token, | |
| 329 | expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 330 | }) satisfies AuthWebSocketTokenResult, | |
| 331 | ), | |
| 332 | ); | |
| 333 | ||
| 334 | const authenticateWebSocketUpgrade: ServerAuthShape["authenticateWebSocketUpgrade"] = (request) => | |
| 335 | Effect.gen(function* () { | |
| 336 | const requestUrl = HttpServerRequest.toURL(request); | |
| 337 | if (Option.isSome(requestUrl)) { | |
| 338 | const websocketToken = requestUrl.value.searchParams.get(WEBSOCKET_TOKEN_QUERY_PARAM); | |
| 339 | if (websocketToken && websocketToken.trim().length > 0) { | |
| 340 | return yield* sessions.verifyWebSocketToken(websocketToken).pipe( | |
| 341 | Effect.map((session) => ({ | |
| 342 | sessionId: session.sessionId, | |
| 343 | subject: session.subject, | |
| 344 | method: session.method, | |
| 345 | role: session.role, | |
| 346 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | |
| 347 | })), | |
| 348 | Effect.mapError( | |
| 349 | (cause) => | |
| 350 | new AuthError({ | |
| 351 | message: "Unauthorized request.", | |
| 352 | status: 401, | |
| 353 | cause, | |
| 354 | }), | |
| 355 | ), | |
| 356 | ); | |
| 357 | } | |
| 358 | } | |
| 359 | ||
| 360 | return yield* authenticateRequest(request); | |
| 361 | }); | |
| 362 | ||
| 363 | return { | |
| 364 | getDescriptor: () => Effect.succeed(descriptor), | |
| 365 | getSessionState, | |
| 366 | exchangeBootstrapCredential, | |
| 367 | exchangeBootstrapCredentialForBearerSession, | |
| 368 | issuePairingCredential, | |
| 369 | listPairingLinks, | |
| 370 | revokePairingLink, | |
| 371 | listClientSessions, | |
| 372 | revokeClientSession, | |
| 373 | revokeOtherClientSessions, | |
| 374 | authenticateHttpRequest: authenticateRequest, | |
| 375 | authenticateWebSocketUpgrade, | |
| 376 | issueWebSocketToken, | |
| 377 | issueStartupPairingUrl, | |
| 378 | } satisfies ServerAuthShape; | |
| 379 | }); | |
| 380 | ||
| 381 | export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe( | |
| 382 | Layer.provideMerge(AuthControlPlaneLive), | |
| 383 | Layer.provideMerge(AuthCoreLive), | |
| 384 | Layer.provideMerge(ServerAuthPolicyLive), | |
| 385 | ); |
apps/server/src/auth/Layers/ServerAuthPolicy.test.ts created+111| ... | ... | @@ -0,0 +1,111 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { Effect, Layer } from "effect"; | |
| 4 | ||
| 5 | import type { ServerConfigShape } from "../../config.ts"; | |
| 6 | import { ServerConfig } from "../../config.ts"; | |
| 7 | import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; | |
| 8 | import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; | |
| 9 | ||
| 10 | const makeServerAuthPolicyLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 11 | ServerAuthPolicyLive.pipe( | |
| 12 | Layer.provide( | |
| 13 | Layer.effect( | |
| 14 | ServerConfig, | |
| 15 | Effect.gen(function* () { | |
| 16 | const config = yield* ServerConfig; | |
| 17 | return { | |
| 18 | ...config, | |
| 19 | ...overrides, | |
| 20 | } satisfies ServerConfigShape; | |
| 21 | }), | |
| 22 | ).pipe( | |
| 23 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })), | |
| 24 | ), | |
| 25 | ), | |
| 26 | ); | |
| 27 | ||
| 28 | it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { | |
| 29 | it.effect("uses desktop-managed-local policy for desktop mode", () => | |
| 30 | Effect.gen(function* () { | |
| 31 | const policy = yield* ServerAuthPolicy; | |
| 32 | const descriptor = yield* policy.getDescriptor(); | |
| 33 | ||
| 34 | expect(descriptor.policy).toBe("desktop-managed-local"); | |
| 35 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); | |
| 36 | }).pipe( | |
| 37 | Effect.provide( | |
| 38 | makeServerAuthPolicyLayer({ | |
| 39 | mode: "desktop", | |
| 40 | }), | |
| 41 | ), | |
| 42 | ), | |
| 43 | ); | |
| 44 | ||
| 45 | it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => | |
| 46 | Effect.gen(function* () { | |
| 47 | const policy = yield* ServerAuthPolicy; | |
| 48 | const descriptor = yield* policy.getDescriptor(); | |
| 49 | ||
| 50 | expect(descriptor.policy).toBe("remote-reachable"); | |
| 51 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]); | |
| 52 | }).pipe( | |
| 53 | Effect.provide( | |
| 54 | makeServerAuthPolicyLayer({ | |
| 55 | mode: "desktop", | |
| 56 | host: "0.0.0.0", | |
| 57 | }), | |
| 58 | ), | |
| 59 | ), | |
| 60 | ); | |
| 61 | ||
| 62 | it.effect("uses loopback-browser policy for loopback web hosts", () => | |
| 63 | Effect.gen(function* () { | |
| 64 | const policy = yield* ServerAuthPolicy; | |
| 65 | const descriptor = yield* policy.getDescriptor(); | |
| 66 | ||
| 67 | expect(descriptor.policy).toBe("loopback-browser"); | |
| 68 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | |
| 69 | }).pipe( | |
| 70 | Effect.provide( | |
| 71 | makeServerAuthPolicyLayer({ | |
| 72 | mode: "web", | |
| 73 | host: "127.0.0.1", | |
| 74 | }), | |
| 75 | ), | |
| 76 | ), | |
| 77 | ); | |
| 78 | ||
| 79 | it.effect("uses remote-reachable policy for wildcard web hosts", () => | |
| 80 | Effect.gen(function* () { | |
| 81 | const policy = yield* ServerAuthPolicy; | |
| 82 | const descriptor = yield* policy.getDescriptor(); | |
| 83 | ||
| 84 | expect(descriptor.policy).toBe("remote-reachable"); | |
| 85 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | |
| 86 | }).pipe( | |
| 87 | Effect.provide( | |
| 88 | makeServerAuthPolicyLayer({ | |
| 89 | mode: "web", | |
| 90 | host: "0.0.0.0", | |
| 91 | }), | |
| 92 | ), | |
| 93 | ), | |
| 94 | ); | |
| 95 | ||
| 96 | it.effect("uses remote-reachable policy for non-loopback web hosts", () => | |
| 97 | Effect.gen(function* () { | |
| 98 | const policy = yield* ServerAuthPolicy; | |
| 99 | const descriptor = yield* policy.getDescriptor(); | |
| 100 | ||
| 101 | expect(descriptor.policy).toBe("remote-reachable"); | |
| 102 | }).pipe( | |
| 103 | Effect.provide( | |
| 104 | makeServerAuthPolicyLayer({ | |
| 105 | mode: "web", | |
| 106 | host: "192.168.1.50", | |
| 107 | }), | |
| 108 | ), | |
| 109 | ), | |
| 110 | ); | |
| 111 | }); |
apps/server/src/auth/Layers/ServerAuthPolicy.ts created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | import type { ServerAuthDescriptor } from "@t3tools/contracts"; | |
| 2 | import { Effect, Layer } from "effect"; | |
| 3 | ||
| 4 | import { ServerConfig } from "../../config.ts"; | |
| 5 | import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; | |
| 6 | import { SESSION_COOKIE_NAME } from "../utils.ts"; | |
| 7 | ||
| 8 | const isWildcardHost = (host: string | undefined): boolean => | |
| 9 | host === "0.0.0.0" || host === "::" || host === "[::]"; | |
| 10 | ||
| 11 | const isLoopbackHost = (host: string | undefined): boolean => { | |
| 12 | if (!host || host.length === 0) { | |
| 13 | return true; | |
| 14 | } | |
| 15 | ||
| 16 | return ( | |
| 17 | host === "localhost" || | |
| 18 | host === "127.0.0.1" || | |
| 19 | host === "::1" || | |
| 20 | host === "[::1]" || | |
| 21 | host.startsWith("127.") | |
| 22 | ); | |
| 23 | }; | |
| 24 | ||
| 25 | export const makeServerAuthPolicy = Effect.gen(function* () { | |
| 26 | const config = yield* ServerConfig; | |
| 27 | const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); | |
| 28 | ||
| 29 | const policy = | |
| 30 | config.mode === "desktop" | |
| 31 | ? isRemoteReachable | |
| 32 | ? "remote-reachable" | |
| 33 | : "desktop-managed-local" | |
| 34 | : isRemoteReachable | |
| 35 | ? "remote-reachable" | |
| 36 | : "loopback-browser"; | |
| 37 | ||
| 38 | const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] = | |
| 39 | policy === "desktop-managed-local" | |
| 40 | ? ["desktop-bootstrap"] | |
| 41 | : config.mode === "desktop" && policy === "remote-reachable" | |
| 42 | ? ["desktop-bootstrap", "one-time-token"] | |
| 43 | : ["one-time-token"]; | |
| 44 | ||
| 45 | const descriptor: ServerAuthDescriptor = { | |
| 46 | policy, | |
| 47 | bootstrapMethods, | |
| 48 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 49 | sessionCookieName: SESSION_COOKIE_NAME, | |
| 50 | }; | |
| 51 | ||
| 52 | return { | |
| 53 | getDescriptor: () => Effect.succeed(descriptor), | |
| 54 | } satisfies ServerAuthPolicyShape; | |
| 55 | }); | |
| 56 | ||
| 57 | export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy); |
apps/server/src/auth/Layers/ServerSecretStore.test.ts created+263| ... | ... | @@ -0,0 +1,263 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { Cause, Deferred, Effect, FileSystem, Layer, Ref } from "effect"; | |
| 4 | import * as PlatformError from "effect/PlatformError"; | |
| 5 | ||
| 6 | import { ServerConfig } from "../../config.ts"; | |
| 7 | import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts"; | |
| 8 | import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 9 | ||
| 10 | const makeServerConfigLayer = () => | |
| 11 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); | |
| 12 | ||
| 13 | const makeServerSecretStoreLayer = () => | |
| 14 | Layer.provide(ServerSecretStoreLive, makeServerConfigLayer()); | |
| 15 | ||
| 16 | const PermissionDeniedFileSystemLayer = Layer.effect( | |
| 17 | FileSystem.FileSystem, | |
| 18 | Effect.gen(function* () { | |
| 19 | const fileSystem = yield* FileSystem.FileSystem; | |
| 20 | ||
| 21 | return { | |
| 22 | ...fileSystem, | |
| 23 | readFile: (path) => | |
| 24 | Effect.fail( | |
| 25 | PlatformError.systemError({ | |
| 26 | _tag: "PermissionDenied", | |
| 27 | module: "FileSystem", | |
| 28 | method: "readFile", | |
| 29 | pathOrDescriptor: path, | |
| 30 | description: "Permission denied while reading secret file.", | |
| 31 | }), | |
| 32 | ), | |
| 33 | } satisfies FileSystem.FileSystem; | |
| 34 | }), | |
| 35 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 36 | ||
| 37 | const makePermissionDeniedSecretStoreLayer = () => | |
| 38 | ServerSecretStoreLive.pipe( | |
| 39 | Layer.provide(makeServerConfigLayer()), | |
| 40 | Layer.provideMerge(PermissionDeniedFileSystemLayer), | |
| 41 | ); | |
| 42 | ||
| 43 | const RenameFailureFileSystemLayer = Layer.effect( | |
| 44 | FileSystem.FileSystem, | |
| 45 | Effect.gen(function* () { | |
| 46 | const fileSystem = yield* FileSystem.FileSystem; | |
| 47 | ||
| 48 | return { | |
| 49 | ...fileSystem, | |
| 50 | rename: (from, to) => | |
| 51 | Effect.fail( | |
| 52 | PlatformError.systemError({ | |
| 53 | _tag: "PermissionDenied", | |
| 54 | module: "FileSystem", | |
| 55 | method: "rename", | |
| 56 | pathOrDescriptor: `${String(from)} -> ${String(to)}`, | |
| 57 | description: "Permission denied while persisting secret file.", | |
| 58 | }), | |
| 59 | ), | |
| 60 | } satisfies FileSystem.FileSystem; | |
| 61 | }), | |
| 62 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 63 | ||
| 64 | const makeRenameFailureSecretStoreLayer = () => | |
| 65 | ServerSecretStoreLive.pipe( | |
| 66 | Layer.provide(makeServerConfigLayer()), | |
| 67 | Layer.provideMerge(RenameFailureFileSystemLayer), | |
| 68 | ); | |
| 69 | ||
| 70 | const RemoveFailureFileSystemLayer = Layer.effect( | |
| 71 | FileSystem.FileSystem, | |
| 72 | Effect.gen(function* () { | |
| 73 | const fileSystem = yield* FileSystem.FileSystem; | |
| 74 | ||
| 75 | return { | |
| 76 | ...fileSystem, | |
| 77 | remove: (path, options) => | |
| 78 | Effect.fail( | |
| 79 | PlatformError.systemError({ | |
| 80 | _tag: "PermissionDenied", | |
| 81 | module: "FileSystem", | |
| 82 | method: "remove", | |
| 83 | pathOrDescriptor: String(path), | |
| 84 | description: `Permission denied while removing secret file.${options ? " options-set" : ""}`, | |
| 85 | }), | |
| 86 | ), | |
| 87 | } satisfies FileSystem.FileSystem; | |
| 88 | }), | |
| 89 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 90 | ||
| 91 | const makeRemoveFailureSecretStoreLayer = () => | |
| 92 | ServerSecretStoreLive.pipe( | |
| 93 | Layer.provide(makeServerConfigLayer()), | |
| 94 | Layer.provideMerge(RemoveFailureFileSystemLayer), | |
| 95 | ); | |
| 96 | ||
| 97 | const ConcurrentReadMissFileSystemLayer = Layer.effect( | |
| 98 | FileSystem.FileSystem, | |
| 99 | Effect.gen(function* () { | |
| 100 | const fileSystem = yield* FileSystem.FileSystem; | |
| 101 | const readCountRef = yield* Ref.make(0); | |
| 102 | const readBarrier = yield* Deferred.make<void>(); | |
| 103 | ||
| 104 | return { | |
| 105 | ...fileSystem, | |
| 106 | readFile: (path) => | |
| 107 | String(path).endsWith("/session-signing-key.bin") | |
| 108 | ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe( | |
| 109 | Effect.flatMap((count) => { | |
| 110 | if (count > 2) { | |
| 111 | return fileSystem.readFile(path); | |
| 112 | } | |
| 113 | return Effect.gen(function* () { | |
| 114 | if (count === 2) { | |
| 115 | yield* Deferred.succeed(readBarrier, void 0); | |
| 116 | } | |
| 117 | yield* Deferred.await(readBarrier); | |
| 118 | return yield* Effect.failCause( | |
| 119 | Cause.fail( | |
| 120 | PlatformError.systemError({ | |
| 121 | _tag: "NotFound", | |
| 122 | module: "FileSystem", | |
| 123 | method: "readFile", | |
| 124 | pathOrDescriptor: String(path), | |
| 125 | description: "Secret file does not exist yet.", | |
| 126 | }), | |
| 127 | ), | |
| 128 | ); | |
| 129 | }); | |
| 130 | }), | |
| 131 | ) | |
| 132 | : fileSystem.readFile(path), | |
| 133 | } satisfies FileSystem.FileSystem; | |
| 134 | }), | |
| 135 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 136 | ||
| 137 | const makeConcurrentCreateSecretStoreLayer = () => | |
| 138 | ServerSecretStoreLive.pipe( | |
| 139 | Layer.provide(makeServerConfigLayer()), | |
| 140 | Layer.provideMerge(ConcurrentReadMissFileSystemLayer), | |
| 141 | ); | |
| 142 | ||
| 143 | it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { | |
| 144 | it.effect("returns null when a secret file does not exist", () => | |
| 145 | Effect.gen(function* () { | |
| 146 | const secretStore = yield* ServerSecretStore; | |
| 147 | ||
| 148 | const secret = yield* secretStore.get("missing-secret"); | |
| 149 | ||
| 150 | expect(secret).toBeNull(); | |
| 151 | }).pipe(Effect.provide(makeServerSecretStoreLayer())), | |
| 152 | ); | |
| 153 | ||
| 154 | it.effect("reuses an existing secret instead of regenerating it", () => | |
| 155 | Effect.gen(function* () { | |
| 156 | const secretStore = yield* ServerSecretStore; | |
| 157 | ||
| 158 | const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32); | |
| 159 | const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32); | |
| 160 | ||
| 161 | expect(Array.from(second)).toEqual(Array.from(first)); | |
| 162 | }).pipe(Effect.provide(makeServerSecretStoreLayer())), | |
| 163 | ); | |
| 164 | ||
| 165 | it.effect("returns the persisted secret when concurrent creators race", () => | |
| 166 | Effect.gen(function* () { | |
| 167 | const secretStore = yield* ServerSecretStore; | |
| 168 | ||
| 169 | const [first, second] = yield* Effect.all( | |
| 170 | [ | |
| 171 | secretStore.getOrCreateRandom("session-signing-key", 32), | |
| 172 | secretStore.getOrCreateRandom("session-signing-key", 32), | |
| 173 | ], | |
| 174 | { concurrency: "unbounded" }, | |
| 175 | ); | |
| 176 | const persisted = yield* secretStore.get("session-signing-key"); | |
| 177 | ||
| 178 | expect(persisted).not.toBeNull(); | |
| 179 | expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array())); | |
| 180 | expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array())); | |
| 181 | }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())), | |
| 182 | ); | |
| 183 | ||
| 184 | it.effect("uses restrictive permissions for the secret directory and files", () => | |
| 185 | Effect.gen(function* () { | |
| 186 | const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = []; | |
| 187 | const recordingFileSystemLayer = Layer.effect( | |
| 188 | FileSystem.FileSystem, | |
| 189 | Effect.gen(function* () { | |
| 190 | const fileSystem = yield* FileSystem.FileSystem; | |
| 191 | ||
| 192 | return { | |
| 193 | ...fileSystem, | |
| 194 | makeDirectory: () => Effect.void, | |
| 195 | writeFile: () => Effect.void, | |
| 196 | rename: () => Effect.void, | |
| 197 | chmod: (path, mode) => | |
| 198 | Effect.sync(() => { | |
| 199 | chmodCalls.push({ path: String(path), mode }); | |
| 200 | }), | |
| 201 | } satisfies FileSystem.FileSystem; | |
| 202 | }), | |
| 203 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 204 | ||
| 205 | const secretStore = yield* Effect.service(ServerSecretStore).pipe( | |
| 206 | Effect.provide( | |
| 207 | ServerSecretStoreLive.pipe( | |
| 208 | Layer.provide(makeServerConfigLayer()), | |
| 209 | Layer.provideMerge(recordingFileSystemLayer), | |
| 210 | ), | |
| 211 | ), | |
| 212 | ); | |
| 213 | ||
| 214 | yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); | |
| 215 | ||
| 216 | expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( | |
| 217 | true, | |
| 218 | ); | |
| 219 | expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); | |
| 220 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 221 | ); | |
| 222 | ||
| 223 | it.effect("propagates read failures other than missing-file errors", () => | |
| 224 | Effect.gen(function* () { | |
| 225 | const secretStore = yield* ServerSecretStore; | |
| 226 | ||
| 227 | const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); | |
| 228 | ||
| 229 | expect(error).toBeInstanceOf(SecretStoreError); | |
| 230 | expect(error.message).toContain("Failed to read secret session-signing-key."); | |
| 231 | expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 232 | expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 233 | }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), | |
| 234 | ); | |
| 235 | ||
| 236 | it.effect("propagates write failures instead of treating them as success", () => | |
| 237 | Effect.gen(function* () { | |
| 238 | const secretStore = yield* ServerSecretStore; | |
| 239 | ||
| 240 | const error = yield* Effect.flip( | |
| 241 | secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), | |
| 242 | ); | |
| 243 | ||
| 244 | expect(error).toBeInstanceOf(SecretStoreError); | |
| 245 | expect(error.message).toContain("Failed to persist secret session-signing-key."); | |
| 246 | expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 247 | expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 248 | }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), | |
| 249 | ); | |
| 250 | ||
| 251 | it.effect("propagates remove failures other than missing-file errors", () => | |
| 252 | Effect.gen(function* () { | |
| 253 | const secretStore = yield* ServerSecretStore; | |
| 254 | ||
| 255 | const error = yield* Effect.flip(secretStore.remove("session-signing-key")); | |
| 256 | ||
| 257 | expect(error).toBeInstanceOf(SecretStoreError); | |
| 258 | expect(error.message).toContain("Failed to remove secret session-signing-key."); | |
| 259 | expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 260 | expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 261 | }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), | |
| 262 | ); | |
| 263 | }); |
apps/server/src/auth/Layers/ServerSecretStore.ts created+151| ... | ... | @@ -0,0 +1,151 @@ |
| 1 | import * as Crypto from "node:crypto"; | |
| 2 | ||
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | |
| 4 | import * as PlatformError from "effect/PlatformError"; | |
| 5 | ||
| 6 | import { ServerConfig } from "../../config.ts"; | |
| 7 | import { | |
| 8 | SecretStoreError, | |
| 9 | ServerSecretStore, | |
| 10 | type ServerSecretStoreShape, | |
| 11 | } from "../Services/ServerSecretStore.ts"; | |
| 12 | ||
| 13 | export const makeServerSecretStore = Effect.gen(function* () { | |
| 14 | const fileSystem = yield* FileSystem.FileSystem; | |
| 15 | const path = yield* Path.Path; | |
| 16 | const serverConfig = yield* ServerConfig; | |
| 17 | ||
| 18 | yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); | |
| 19 | yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( | |
| 20 | Effect.mapError( | |
| 21 | (cause) => | |
| 22 | new SecretStoreError({ | |
| 23 | message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, | |
| 24 | cause, | |
| 25 | }), | |
| 26 | ), | |
| 27 | ); | |
| 28 | ||
| 29 | const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); | |
| 30 | ||
| 31 | const isMissingSecretFileError = (cause: unknown): cause is PlatformError.PlatformError => | |
| 32 | cause instanceof PlatformError.PlatformError && cause.reason._tag === "NotFound"; | |
| 33 | ||
| 34 | const isAlreadyExistsSecretFileError = (cause: unknown): cause is PlatformError.PlatformError => | |
| 35 | cause instanceof PlatformError.PlatformError && cause.reason._tag === "AlreadyExists"; | |
| 36 | ||
| 37 | const get: ServerSecretStoreShape["get"] = (name) => | |
| 38 | fileSystem.readFile(resolveSecretPath(name)).pipe( | |
| 39 | Effect.map((bytes) => Uint8Array.from(bytes)), | |
| 40 | Effect.catch((cause) => | |
| 41 | isMissingSecretFileError(cause) | |
| 42 | ? Effect.succeed(null) | |
| 43 | : Effect.fail( | |
| 44 | new SecretStoreError({ | |
| 45 | message: `Failed to read secret ${name}.`, | |
| 46 | cause, | |
| 47 | }), | |
| 48 | ), | |
| 49 | ), | |
| 50 | ); | |
| 51 | ||
| 52 | const set: ServerSecretStoreShape["set"] = (name, value) => { | |
| 53 | const secretPath = resolveSecretPath(name); | |
| 54 | const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; | |
| 55 | return Effect.gen(function* () { | |
| 56 | yield* fileSystem.writeFile(tempPath, value); | |
| 57 | yield* fileSystem.chmod(tempPath, 0o600); | |
| 58 | yield* fileSystem.rename(tempPath, secretPath); | |
| 59 | yield* fileSystem.chmod(secretPath, 0o600); | |
| 60 | }).pipe( | |
| 61 | Effect.catch((cause) => | |
| 62 | fileSystem.remove(tempPath).pipe( | |
| 63 | Effect.ignore, | |
| 64 | Effect.flatMap(() => | |
| 65 | Effect.fail( | |
| 66 | new SecretStoreError({ | |
| 67 | message: `Failed to persist secret ${name}.`, | |
| 68 | cause, | |
| 69 | }), | |
| 70 | ), | |
| 71 | ), | |
| 72 | ), | |
| 73 | ), | |
| 74 | ); | |
| 75 | }; | |
| 76 | ||
| 77 | const create: ServerSecretStoreShape["set"] = (name, value) => { | |
| 78 | const secretPath = resolveSecretPath(name); | |
| 79 | return Effect.scoped( | |
| 80 | Effect.gen(function* () { | |
| 81 | const file = yield* fileSystem.open(secretPath, { | |
| 82 | flag: "wx", | |
| 83 | mode: 0o600, | |
| 84 | }); | |
| 85 | yield* file.writeAll(value); | |
| 86 | yield* file.sync; | |
| 87 | yield* fileSystem.chmod(secretPath, 0o600); | |
| 88 | }), | |
| 89 | ).pipe( | |
| 90 | Effect.mapError( | |
| 91 | (cause) => | |
| 92 | new SecretStoreError({ | |
| 93 | message: `Failed to persist secret ${name}.`, | |
| 94 | cause, | |
| 95 | }), | |
| 96 | ), | |
| 97 | ); | |
| 98 | }; | |
| 99 | ||
| 100 | const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => | |
| 101 | get(name).pipe( | |
| 102 | Effect.flatMap((existing) => { | |
| 103 | if (existing) { | |
| 104 | return Effect.succeed(existing); | |
| 105 | } | |
| 106 | ||
| 107 | const generated = Crypto.randomBytes(bytes); | |
| 108 | return create(name, generated).pipe( | |
| 109 | Effect.as(Uint8Array.from(generated)), | |
| 110 | Effect.catchTag("SecretStoreError", (error) => | |
| 111 | isAlreadyExistsSecretFileError(error.cause) | |
| 112 | ? get(name).pipe( | |
| 113 | Effect.flatMap((created) => | |
| 114 | created !== null | |
| 115 | ? Effect.succeed(created) | |
| 116 | : Effect.fail( | |
| 117 | new SecretStoreError({ | |
| 118 | message: `Failed to read secret ${name} after concurrent creation.`, | |
| 119 | }), | |
| 120 | ), | |
| 121 | ), | |
| 122 | ) | |
| 123 | : Effect.fail(error), | |
| 124 | ), | |
| 125 | ); | |
| 126 | }), | |
| 127 | ); | |
| 128 | ||
| 129 | const remove: ServerSecretStoreShape["remove"] = (name) => | |
| 130 | fileSystem.remove(resolveSecretPath(name)).pipe( | |
| 131 | Effect.catch((cause) => | |
| 132 | isMissingSecretFileError(cause) | |
| 133 | ? Effect.void | |
| 134 | : Effect.fail( | |
| 135 | new SecretStoreError({ | |
| 136 | message: `Failed to remove secret ${name}.`, | |
| 137 | cause, | |
| 138 | }), | |
| 139 | ), | |
| 140 | ), | |
| 141 | ); | |
| 142 | ||
| 143 | return { | |
| 144 | get, | |
| 145 | set, | |
| 146 | getOrCreateRandom, | |
| 147 | remove, | |
| 148 | } satisfies ServerSecretStoreShape; | |
| 149 | }); | |
| 150 | ||
| 151 | export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore); |
apps/server/src/auth/Layers/SessionCredentialService.test.ts created+191| ... | ... | @@ -0,0 +1,191 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { Duration, Effect, Layer } from "effect"; | |
| 4 | import { TestClock } from "effect/testing"; | |
| 5 | ||
| 6 | import type { ServerConfigShape } from "../../config.ts"; | |
| 7 | import { ServerConfig } from "../../config.ts"; | |
| 8 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 9 | import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 10 | import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 11 | import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; | |
| 12 | ||
| 13 | const makeServerConfigLayer = ( | |
| 14 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 15 | ) => | |
| 16 | Layer.effect( | |
| 17 | ServerConfig, | |
| 18 | Effect.gen(function* () { | |
| 19 | const config = yield* ServerConfig; | |
| 20 | return { | |
| 21 | ...config, | |
| 22 | ...overrides, | |
| 23 | } satisfies ServerConfigShape; | |
| 24 | }), | |
| 25 | ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); | |
| 26 | ||
| 27 | const makeSessionCredentialLayer = ( | |
| 28 | overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 29 | ) => | |
| 30 | SessionCredentialServiceLive.pipe( | |
| 31 | Layer.provide(SqlitePersistenceMemory), | |
| 32 | Layer.provide(ServerSecretStoreLive), | |
| 33 | Layer.provide(makeServerConfigLayer(overrides)), | |
| 34 | ); | |
| 35 | ||
| 36 | it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { | |
| 37 | it.effect("issues and verifies signed browser session tokens", () => | |
| 38 | Effect.gen(function* () { | |
| 39 | const sessions = yield* SessionCredentialService; | |
| 40 | const issued = yield* sessions.issue({ | |
| 41 | subject: "desktop-bootstrap", | |
| 42 | role: "owner", | |
| 43 | client: { | |
| 44 | label: "Desktop app", | |
| 45 | deviceType: "desktop", | |
| 46 | os: "macOS", | |
| 47 | browser: "Electron", | |
| 48 | ipAddress: "127.0.0.1", | |
| 49 | }, | |
| 50 | }); | |
| 51 | const verified = yield* sessions.verify(issued.token); | |
| 52 | ||
| 53 | expect(verified.method).toBe("browser-session-cookie"); | |
| 54 | expect(verified.subject).toBe("desktop-bootstrap"); | |
| 55 | expect(verified.role).toBe("owner"); | |
| 56 | expect(verified.client.label).toBe("Desktop app"); | |
| 57 | expect(verified.client.browser).toBe("Electron"); | |
| 58 | expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); | |
| 59 | }).pipe(Effect.provide(makeSessionCredentialLayer())), | |
| 60 | ); | |
| 61 | it.effect("rejects malformed session tokens", () => | |
| 62 | Effect.gen(function* () { | |
| 63 | const sessions = yield* SessionCredentialService; | |
| 64 | const error = yield* Effect.flip(sessions.verify("not-a-session-token")); | |
| 65 | ||
| 66 | expect(error._tag).toBe("SessionCredentialError"); | |
| 67 | expect(error.message).toContain("Malformed session token"); | |
| 68 | }).pipe(Effect.provide(makeSessionCredentialLayer())), | |
| 69 | ); | |
| 70 | it.effect("verifies session tokens against the Effect clock", () => | |
| 71 | Effect.gen(function* () { | |
| 72 | const sessions = yield* SessionCredentialService; | |
| 73 | const issued = yield* sessions.issue({ | |
| 74 | method: "bearer-session-token", | |
| 75 | subject: "test-clock", | |
| 76 | }); | |
| 77 | const verified = yield* sessions.verify(issued.token); | |
| 78 | ||
| 79 | expect(verified.method).toBe("bearer-session-token"); | |
| 80 | expect(verified.subject).toBe("test-clock"); | |
| 81 | expect(verified.role).toBe("client"); | |
| 82 | }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), | |
| 83 | ); | |
| 84 | ||
| 85 | it.effect("rejects websocket tokens once the parent session has expired", () => | |
| 86 | Effect.gen(function* () { | |
| 87 | const sessions = yield* SessionCredentialService; | |
| 88 | const issued = yield* sessions.issue({ | |
| 89 | method: "bearer-session-token", | |
| 90 | subject: "short-lived", | |
| 91 | ttl: Duration.seconds(1), | |
| 92 | }); | |
| 93 | const websocket = yield* sessions.issueWebSocketToken(issued.sessionId); | |
| 94 | ||
| 95 | yield* TestClock.adjust(Duration.seconds(2)); | |
| 96 | ||
| 97 | const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); | |
| 98 | expect(error.message).toContain("expired"); | |
| 99 | }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), | |
| 100 | ); | |
| 101 | ||
| 102 | it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () => | |
| 103 | Effect.gen(function* () { | |
| 104 | const sessions = yield* SessionCredentialService; | |
| 105 | const owner = yield* sessions.issue({ | |
| 106 | subject: "desktop-bootstrap", | |
| 107 | role: "owner", | |
| 108 | client: { | |
| 109 | label: "Desktop app", | |
| 110 | deviceType: "desktop", | |
| 111 | os: "macOS", | |
| 112 | browser: "Electron", | |
| 113 | }, | |
| 114 | }); | |
| 115 | const client = yield* sessions.issue({ | |
| 116 | subject: "one-time-token", | |
| 117 | role: "client", | |
| 118 | client: { | |
| 119 | label: "Julius iPhone", | |
| 120 | deviceType: "mobile", | |
| 121 | os: "iOS", | |
| 122 | browser: "Safari", | |
| 123 | ipAddress: "192.168.1.88", | |
| 124 | }, | |
| 125 | }); | |
| 126 | ||
| 127 | yield* sessions.markConnected(client.sessionId); | |
| 128 | const beforeRevoke = yield* sessions.listActive(); | |
| 129 | const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId); | |
| 130 | const afterRevoke = yield* sessions.listActive(); | |
| 131 | const revokedClient = yield* Effect.flip(sessions.verify(client.token)); | |
| 132 | ||
| 133 | expect(beforeRevoke).toHaveLength(2); | |
| 134 | expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( | |
| 135 | true, | |
| 136 | ); | |
| 137 | expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe( | |
| 138 | "Julius iPhone", | |
| 139 | ); | |
| 140 | expect( | |
| 141 | beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType, | |
| 142 | ).toBe("desktop"); | |
| 143 | expect(revokedCount).toBe(1); | |
| 144 | expect(afterRevoke).toHaveLength(1); | |
| 145 | expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId); | |
| 146 | expect(revokedClient.message).toContain("revoked"); | |
| 147 | }).pipe(Effect.provide(makeSessionCredentialLayer())), | |
| 148 | ); | |
| 149 | ||
| 150 | it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () => | |
| 151 | Effect.gen(function* () { | |
| 152 | const sessions = yield* SessionCredentialService; | |
| 153 | const issued = yield* sessions.issue({ | |
| 154 | subject: "reconnect-test", | |
| 155 | method: "bearer-session-token", | |
| 156 | }); | |
| 157 | ||
| 158 | const beforeConnect = yield* sessions.listActive(); | |
| 159 | expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); | |
| 160 | ||
| 161 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 162 | yield* sessions.markConnected(issued.sessionId); | |
| 163 | const firstConnect = yield* sessions.listActive(); | |
| 164 | const firstConnectedAt = firstConnect[0]?.lastConnectedAt; | |
| 165 | ||
| 166 | expect(firstConnect[0]?.connected).toBe(true); | |
| 167 | expect(firstConnectedAt).not.toBeNull(); | |
| 168 | ||
| 169 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 170 | yield* sessions.markConnected(issued.sessionId); | |
| 171 | const stillConnected = yield* sessions.listActive(); | |
| 172 | ||
| 173 | expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); | |
| 174 | ||
| 175 | yield* sessions.markDisconnected(issued.sessionId); | |
| 176 | yield* sessions.markDisconnected(issued.sessionId); | |
| 177 | const afterDisconnect = yield* sessions.listActive(); | |
| 178 | ||
| 179 | expect(afterDisconnect[0]?.connected).toBe(false); | |
| 180 | expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); | |
| 181 | ||
| 182 | yield* TestClock.adjust(Duration.seconds(1)); | |
| 183 | yield* sessions.markConnected(issued.sessionId); | |
| 184 | const afterReconnect = yield* sessions.listActive(); | |
| 185 | ||
| 186 | expect(afterReconnect[0]?.connected).toBe(true); | |
| 187 | expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull(); | |
| 188 | expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); | |
| 189 | }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), | |
| 190 | ); | |
| 191 | }); |
apps/server/src/auth/Layers/SessionCredentialService.ts created+493| ... | ... | @@ -0,0 +1,493 @@ |
| 1 | import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts"; | |
| 2 | import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; | |
| 3 | import { Option } from "effect"; | |
| 4 | ||
| 5 | import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; | |
| 6 | import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; | |
| 7 | import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; | |
| 8 | import { SESSION_COOKIE_NAME } from "../utils.ts"; | |
| 9 | import { | |
| 10 | SessionCredentialError, | |
| 11 | SessionCredentialService, | |
| 12 | type IssuedSession, | |
| 13 | type SessionCredentialChange, | |
| 14 | type SessionCredentialServiceShape, | |
| 15 | type VerifiedSession, | |
| 16 | } from "../Services/SessionCredentialService.ts"; | |
| 17 | import { | |
| 18 | base64UrlDecodeUtf8, | |
| 19 | base64UrlEncode, | |
| 20 | signPayload, | |
| 21 | timingSafeEqualBase64Url, | |
| 22 | } from "../utils.ts"; | |
| 23 | ||
| 24 | const SIGNING_SECRET_NAME = "server-signing-key"; | |
| 25 | const DEFAULT_SESSION_TTL = Duration.days(30); | |
| 26 | const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); | |
| 27 | ||
| 28 | const SessionClaims = Schema.Struct({ | |
| 29 | v: Schema.Literal(1), | |
| 30 | kind: Schema.Literal("session"), | |
| 31 | sid: AuthSessionId, | |
| 32 | sub: Schema.String, | |
| 33 | role: Schema.Literals(["owner", "client"]), | |
| 34 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | |
| 35 | iat: Schema.Number, | |
| 36 | exp: Schema.Number, | |
| 37 | }); | |
| 38 | type SessionClaims = typeof SessionClaims.Type; | |
| 39 | ||
| 40 | const WebSocketClaims = Schema.Struct({ | |
| 41 | v: Schema.Literal(1), | |
| 42 | kind: Schema.Literal("websocket"), | |
| 43 | sid: AuthSessionId, | |
| 44 | iat: Schema.Number, | |
| 45 | exp: Schema.Number, | |
| 46 | }); | |
| 47 | type WebSocketClaims = typeof WebSocketClaims.Type; | |
| 48 | ||
| 49 | function createDefaultClientMetadata(): AuthClientMetadata { | |
| 50 | return { | |
| 51 | deviceType: "unknown", | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | function toClientMetadata(record: { | |
| 56 | readonly label: string | null; | |
| 57 | readonly ipAddress: string | null; | |
| 58 | readonly userAgent: string | null; | |
| 59 | readonly deviceType: AuthClientMetadata["deviceType"]; | |
| 60 | readonly os: string | null; | |
| 61 | readonly browser: string | null; | |
| 62 | }): AuthClientMetadata { | |
| 63 | return { | |
| 64 | ...(record.label ? { label: record.label } : {}), | |
| 65 | ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), | |
| 66 | ...(record.userAgent ? { userAgent: record.userAgent } : {}), | |
| 67 | deviceType: record.deviceType, | |
| 68 | ...(record.os ? { os: record.os } : {}), | |
| 69 | ...(record.browser ? { browser: record.browser } : {}), | |
| 70 | }; | |
| 71 | } | |
| 72 | ||
| 73 | function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession { | |
| 74 | return { | |
| 75 | ...input, | |
| 76 | current: false, | |
| 77 | }; | |
| 78 | } | |
| 79 | ||
| 80 | export const makeSessionCredentialService = Effect.gen(function* () { | |
| 81 | const secretStore = yield* ServerSecretStore; | |
| 82 | const authSessions = yield* AuthSessionRepository; | |
| 83 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); | |
| 84 | const connectedSessionsRef = yield* Ref.make(new Map<string, number>()); | |
| 85 | const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); | |
| 86 | ||
| 87 | const toSessionCredentialError = (message: string) => (cause: unknown) => | |
| 88 | new SessionCredentialError({ | |
| 89 | message, | |
| 90 | cause, | |
| 91 | }); | |
| 92 | ||
| 93 | const emitUpsert = (clientSession: AuthClientSession) => | |
| 94 | PubSub.publish(changesPubSub, { | |
| 95 | type: "clientUpserted", | |
| 96 | clientSession, | |
| 97 | }).pipe(Effect.asVoid); | |
| 98 | ||
| 99 | const emitRemoved = (sessionId: AuthSessionId) => | |
| 100 | PubSub.publish(changesPubSub, { | |
| 101 | type: "clientRemoved", | |
| 102 | sessionId, | |
| 103 | }).pipe(Effect.asVoid); | |
| 104 | ||
| 105 | const loadActiveSession = (sessionId: AuthSessionId) => | |
| 106 | Effect.gen(function* () { | |
| 107 | const row = yield* authSessions.getById({ sessionId }); | |
| 108 | if (Option.isNone(row) || row.value.revokedAt !== null) { | |
| 109 | return Option.none<AuthClientSession>(); | |
| 110 | } | |
| 111 | ||
| 112 | const connectedSessions = yield* Ref.get(connectedSessionsRef); | |
| 113 | return Option.some( | |
| 114 | toAuthClientSession({ | |
| 115 | sessionId: row.value.sessionId, | |
| 116 | subject: row.value.subject, | |
| 117 | role: row.value.role, | |
| 118 | method: row.value.method, | |
| 119 | client: toClientMetadata(row.value.client), | |
| 120 | issuedAt: row.value.issuedAt, | |
| 121 | expiresAt: row.value.expiresAt, | |
| 122 | lastConnectedAt: row.value.lastConnectedAt, | |
| 123 | connected: connectedSessions.has(row.value.sessionId), | |
| 124 | }), | |
| 125 | ); | |
| 126 | }); | |
| 127 | ||
| 128 | const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) => | |
| 129 | Ref.modify(connectedSessionsRef, (current) => { | |
| 130 | const next = new Map(current); | |
| 131 | const wasDisconnected = !next.has(sessionId); | |
| 132 | next.set(sessionId, (next.get(sessionId) ?? 0) + 1); | |
| 133 | return [wasDisconnected, next] as const; | |
| 134 | }).pipe( | |
| 135 | Effect.flatMap((wasDisconnected) => | |
| 136 | wasDisconnected | |
| 137 | ? DateTime.now.pipe( | |
| 138 | Effect.flatMap((lastConnectedAt) => | |
| 139 | authSessions.setLastConnectedAt({ | |
| 140 | sessionId, | |
| 141 | lastConnectedAt, | |
| 142 | }), | |
| 143 | ), | |
| 144 | ) | |
| 145 | : Effect.void, | |
| 146 | ), | |
| 147 | Effect.flatMap(() => loadActiveSession(sessionId)), | |
| 148 | Effect.flatMap((session) => | |
| 149 | Option.isSome(session) ? emitUpsert(session.value) : Effect.void, | |
| 150 | ), | |
| 151 | Effect.catchCause((cause) => | |
| 152 | Effect.logError("Failed to publish connected-session auth update.").pipe( | |
| 153 | Effect.annotateLogs({ | |
| 154 | sessionId, | |
| 155 | cause, | |
| 156 | }), | |
| 157 | ), | |
| 158 | ), | |
| 159 | ); | |
| 160 | ||
| 161 | const markDisconnected: SessionCredentialServiceShape["markDisconnected"] = (sessionId) => | |
| 162 | Ref.update(connectedSessionsRef, (current) => { | |
| 163 | const next = new Map(current); | |
| 164 | const remaining = (next.get(sessionId) ?? 0) - 1; | |
| 165 | if (remaining > 0) { | |
| 166 | next.set(sessionId, remaining); | |
| 167 | } else { | |
| 168 | next.delete(sessionId); | |
| 169 | } | |
| 170 | return next; | |
| 171 | }).pipe( | |
| 172 | Effect.flatMap(() => loadActiveSession(sessionId)), | |
| 173 | Effect.flatMap((session) => | |
| 174 | Option.isSome(session) ? emitUpsert(session.value) : Effect.void, | |
| 175 | ), | |
| 176 | Effect.catchCause((cause) => | |
| 177 | Effect.logError("Failed to publish disconnected-session auth update.").pipe( | |
| 178 | Effect.annotateLogs({ | |
| 179 | sessionId, | |
| 180 | cause, | |
| 181 | }), | |
| 182 | ), | |
| 183 | ), | |
| 184 | ); | |
| 185 | ||
| 186 | const issue: SessionCredentialServiceShape["issue"] = (input) => | |
| 187 | Effect.gen(function* () { | |
| 188 | const sessionId = AuthSessionId.makeUnsafe(crypto.randomUUID()); | |
| 189 | const issuedAt = yield* DateTime.now; | |
| 190 | const expiresAt = DateTime.add(issuedAt, { | |
| 191 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), | |
| 192 | }); | |
| 193 | const claims: SessionClaims = { | |
| 194 | v: 1, | |
| 195 | kind: "session", | |
| 196 | sid: sessionId, | |
| 197 | sub: input?.subject ?? "browser", | |
| 198 | role: input?.role ?? "client", | |
| 199 | method: input?.method ?? "browser-session-cookie", | |
| 200 | iat: issuedAt.epochMilliseconds, | |
| 201 | exp: expiresAt.epochMilliseconds, | |
| 202 | }; | |
| 203 | const encodedPayload = base64UrlEncode(JSON.stringify(claims)); | |
| 204 | const signature = signPayload(encodedPayload, signingSecret); | |
| 205 | const client = input?.client ?? createDefaultClientMetadata(); | |
| 206 | yield* authSessions.create({ | |
| 207 | sessionId, | |
| 208 | subject: claims.sub, | |
| 209 | role: claims.role, | |
| 210 | method: claims.method, | |
| 211 | client: { | |
| 212 | label: client.label ?? null, | |
| 213 | ipAddress: client.ipAddress ?? null, | |
| 214 | userAgent: client.userAgent ?? null, | |
| 215 | deviceType: client.deviceType, | |
| 216 | os: client.os ?? null, | |
| 217 | browser: client.browser ?? null, | |
| 218 | }, | |
| 219 | issuedAt, | |
| 220 | expiresAt, | |
| 221 | }); | |
| 222 | yield* emitUpsert( | |
| 223 | toAuthClientSession({ | |
| 224 | sessionId, | |
| 225 | subject: claims.sub, | |
| 226 | role: claims.role, | |
| 227 | method: claims.method, | |
| 228 | client, | |
| 229 | issuedAt, | |
| 230 | expiresAt, | |
| 231 | lastConnectedAt: null, | |
| 232 | connected: false, | |
| 233 | }), | |
| 234 | ); | |
| 235 | ||
| 236 | return { | |
| 237 | sessionId, | |
| 238 | token: `${encodedPayload}.${signature}`, | |
| 239 | method: claims.method, | |
| 240 | client, | |
| 241 | expiresAt: expiresAt, | |
| 242 | role: claims.role, | |
| 243 | } satisfies IssuedSession; | |
| 244 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential."))); | |
| 245 | ||
| 246 | const verify: SessionCredentialServiceShape["verify"] = (token) => | |
| 247 | Effect.gen(function* () { | |
| 248 | const [encodedPayload, signature] = token.split("."); | |
| 249 | if (!encodedPayload || !signature) { | |
| 250 | return yield* new SessionCredentialError({ | |
| 251 | message: "Malformed session token.", | |
| 252 | }); | |
| 253 | } | |
| 254 | ||
| 255 | const expectedSignature = signPayload(encodedPayload, signingSecret); | |
| 256 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | |
| 257 | return yield* new SessionCredentialError({ | |
| 258 | message: "Invalid session token signature.", | |
| 259 | }); | |
| 260 | } | |
| 261 | ||
| 262 | const claims = yield* Effect.try({ | |
| 263 | try: () => | |
| 264 | Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))), | |
| 265 | catch: (cause) => | |
| 266 | new SessionCredentialError({ | |
| 267 | message: "Invalid session token payload.", | |
| 268 | cause, | |
| 269 | }), | |
| 270 | }); | |
| 271 | ||
| 272 | const now = yield* Clock.currentTimeMillis; | |
| 273 | if (claims.exp <= now) { | |
| 274 | return yield* new SessionCredentialError({ | |
| 275 | message: "Session token expired.", | |
| 276 | }); | |
| 277 | } | |
| 278 | ||
| 279 | const row = yield* authSessions.getById({ sessionId: claims.sid }); | |
| 280 | if (Option.isNone(row)) { | |
| 281 | return yield* new SessionCredentialError({ | |
| 282 | message: "Unknown session token.", | |
| 283 | }); | |
| 284 | } | |
| 285 | if (row.value.revokedAt !== null) { | |
| 286 | return yield* new SessionCredentialError({ | |
| 287 | message: "Session token revoked.", | |
| 288 | }); | |
| 289 | } | |
| 290 | ||
| 291 | return { | |
| 292 | sessionId: claims.sid, | |
| 293 | token, | |
| 294 | method: claims.method, | |
| 295 | client: toClientMetadata(row.value.client), | |
| 296 | expiresAt: DateTime.makeUnsafe(claims.exp), | |
| 297 | subject: claims.sub, | |
| 298 | role: claims.role, | |
| 299 | } satisfies VerifiedSession; | |
| 300 | }).pipe( | |
| 301 | Effect.mapError((cause) => | |
| 302 | cause instanceof SessionCredentialError | |
| 303 | ? cause | |
| 304 | : new SessionCredentialError({ | |
| 305 | message: "Failed to verify session credential.", | |
| 306 | cause, | |
| 307 | }), | |
| 308 | ), | |
| 309 | ); | |
| 310 | ||
| 311 | const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = ( | |
| 312 | sessionId, | |
| 313 | input, | |
| 314 | ) => | |
| 315 | Effect.gen(function* () { | |
| 316 | const issuedAt = yield* DateTime.now; | |
| 317 | const expiresAt = DateTime.add(issuedAt, { | |
| 318 | milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), | |
| 319 | }); | |
| 320 | const claims: WebSocketClaims = { | |
| 321 | v: 1, | |
| 322 | kind: "websocket", | |
| 323 | sid: sessionId, | |
| 324 | iat: issuedAt.epochMilliseconds, | |
| 325 | exp: expiresAt.epochMilliseconds, | |
| 326 | }; | |
| 327 | const encodedPayload = base64UrlEncode(JSON.stringify(claims)); | |
| 328 | const signature = signPayload(encodedPayload, signingSecret); | |
| 329 | return { | |
| 330 | token: `${encodedPayload}.${signature}`, | |
| 331 | expiresAt, | |
| 332 | }; | |
| 333 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token."))); | |
| 334 | ||
| 335 | const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) => | |
| 336 | Effect.gen(function* () { | |
| 337 | const [encodedPayload, signature] = token.split("."); | |
| 338 | if (!encodedPayload || !signature) { | |
| 339 | return yield* new SessionCredentialError({ | |
| 340 | message: "Malformed websocket token.", | |
| 341 | }); | |
| 342 | } | |
| 343 | ||
| 344 | const expectedSignature = signPayload(encodedPayload, signingSecret); | |
| 345 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | |
| 346 | return yield* new SessionCredentialError({ | |
| 347 | message: "Invalid websocket token signature.", | |
| 348 | }); | |
| 349 | } | |
| 350 | ||
| 351 | const claims = yield* Effect.try({ | |
| 352 | try: () => | |
| 353 | Schema.decodeUnknownSync(WebSocketClaims)( | |
| 354 | JSON.parse(base64UrlDecodeUtf8(encodedPayload)), | |
| 355 | ), | |
| 356 | catch: (cause) => | |
| 357 | new SessionCredentialError({ | |
| 358 | message: "Invalid websocket token payload.", | |
| 359 | cause, | |
| 360 | }), | |
| 361 | }); | |
| 362 | ||
| 363 | const now = yield* Clock.currentTimeMillis; | |
| 364 | if (claims.exp <= now) { | |
| 365 | return yield* new SessionCredentialError({ | |
| 366 | message: "Websocket token expired.", | |
| 367 | }); | |
| 368 | } | |
| 369 | ||
| 370 | const row = yield* authSessions.getById({ sessionId: claims.sid }); | |
| 371 | if (Option.isNone(row)) { | |
| 372 | return yield* new SessionCredentialError({ | |
| 373 | message: "Unknown websocket session.", | |
| 374 | }); | |
| 375 | } | |
| 376 | if (row.value.expiresAt.epochMilliseconds <= now) { | |
| 377 | return yield* new SessionCredentialError({ | |
| 378 | message: "Websocket session expired.", | |
| 379 | }); | |
| 380 | } | |
| 381 | if (row.value.revokedAt !== null) { | |
| 382 | return yield* new SessionCredentialError({ | |
| 383 | message: "Websocket session revoked.", | |
| 384 | }); | |
| 385 | } | |
| 386 | ||
| 387 | return { | |
| 388 | sessionId: row.value.sessionId, | |
| 389 | token, | |
| 390 | method: row.value.method, | |
| 391 | client: toClientMetadata(row.value.client), | |
| 392 | expiresAt: row.value.expiresAt, | |
| 393 | subject: row.value.subject, | |
| 394 | role: row.value.role, | |
| 395 | } satisfies VerifiedSession; | |
| 396 | }).pipe( | |
| 397 | Effect.mapError((cause) => | |
| 398 | cause instanceof SessionCredentialError | |
| 399 | ? cause | |
| 400 | : new SessionCredentialError({ | |
| 401 | message: "Failed to verify websocket token.", | |
| 402 | cause, | |
| 403 | }), | |
| 404 | ), | |
| 405 | ); | |
| 406 | ||
| 407 | const listActive: SessionCredentialServiceShape["listActive"] = () => | |
| 408 | Effect.gen(function* () { | |
| 409 | const now = yield* DateTime.now; | |
| 410 | const connectedSessions = yield* Ref.get(connectedSessionsRef); | |
| 411 | const rows = yield* authSessions.listActive({ now }); | |
| 412 | ||
| 413 | return rows.map((row) => | |
| 414 | toAuthClientSession({ | |
| 415 | sessionId: row.sessionId, | |
| 416 | subject: row.subject, | |
| 417 | role: row.role, | |
| 418 | method: row.method, | |
| 419 | client: toClientMetadata(row.client), | |
| 420 | issuedAt: row.issuedAt, | |
| 421 | expiresAt: row.expiresAt, | |
| 422 | lastConnectedAt: row.lastConnectedAt, | |
| 423 | connected: connectedSessions.has(row.sessionId), | |
| 424 | }), | |
| 425 | ); | |
| 426 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions."))); | |
| 427 | ||
| 428 | const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) => | |
| 429 | Effect.gen(function* () { | |
| 430 | const revokedAt = yield* DateTime.now; | |
| 431 | const revoked = yield* authSessions.revoke({ | |
| 432 | sessionId, | |
| 433 | revokedAt, | |
| 434 | }); | |
| 435 | if (revoked) { | |
| 436 | yield* Ref.update(connectedSessionsRef, (current) => { | |
| 437 | const next = new Map(current); | |
| 438 | next.delete(sessionId); | |
| 439 | return next; | |
| 440 | }); | |
| 441 | yield* emitRemoved(sessionId); | |
| 442 | } | |
| 443 | return revoked; | |
| 444 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session."))); | |
| 445 | ||
| 446 | const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) => | |
| 447 | Effect.gen(function* () { | |
| 448 | const revokedAt = yield* DateTime.now; | |
| 449 | const revokedSessionIds = yield* authSessions.revokeAllExcept({ | |
| 450 | currentSessionId: sessionId, | |
| 451 | revokedAt, | |
| 452 | }); | |
| 453 | if (revokedSessionIds.length > 0) { | |
| 454 | yield* Ref.update(connectedSessionsRef, (current) => { | |
| 455 | const next = new Map(current); | |
| 456 | for (const revokedSessionId of revokedSessionIds) { | |
| 457 | next.delete(revokedSessionId); | |
| 458 | } | |
| 459 | return next; | |
| 460 | }); | |
| 461 | yield* Effect.forEach( | |
| 462 | revokedSessionIds, | |
| 463 | (revokedSessionId) => emitRemoved(revokedSessionId), | |
| 464 | { | |
| 465 | concurrency: "unbounded", | |
| 466 | discard: true, | |
| 467 | }, | |
| 468 | ); | |
| 469 | } | |
| 470 | return revokedSessionIds.length; | |
| 471 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); | |
| 472 | ||
| 473 | return { | |
| 474 | cookieName: SESSION_COOKIE_NAME, | |
| 475 | issue, | |
| 476 | verify, | |
| 477 | issueWebSocketToken, | |
| 478 | verifyWebSocketToken, | |
| 479 | listActive, | |
| 480 | get streamChanges() { | |
| 481 | return Stream.fromPubSub(changesPubSub); | |
| 482 | }, | |
| 483 | revoke, | |
| 484 | revokeAllExcept, | |
| 485 | markConnected, | |
| 486 | markDisconnected, | |
| 487 | } satisfies SessionCredentialServiceShape; | |
| 488 | }); | |
| 489 | ||
| 490 | export const SessionCredentialServiceLive = Layer.effect( | |
| 491 | SessionCredentialService, | |
| 492 | makeSessionCredentialService, | |
| 493 | ).pipe(Layer.provideMerge(AuthSessionRepositoryLive)); |
apps/server/src/auth/Services/AuthControlPlane.ts created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | import type { | |
| 2 | AuthClientMetadata, | |
| 3 | AuthClientSession, | |
| 4 | AuthPairingLink, | |
| 5 | AuthSessionId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { Data, DateTime, Duration, Effect, ServiceMap } from "effect"; | |
| 8 | import { SessionRole } from "./SessionCredentialService"; | |
| 9 | ||
| 10 | export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; | |
| 11 | ||
| 12 | export interface IssuedPairingLink { | |
| 13 | readonly id: string; | |
| 14 | readonly credential: string; | |
| 15 | readonly role: SessionRole; | |
| 16 | readonly subject: string; | |
| 17 | readonly label?: string; | |
| 18 | readonly createdAt: DateTime.Utc; | |
| 19 | readonly expiresAt: DateTime.Utc; | |
| 20 | } | |
| 21 | ||
| 22 | export interface IssuedBearerSession { | |
| 23 | readonly sessionId: AuthSessionId; | |
| 24 | readonly token: string; | |
| 25 | readonly method: "bearer-session-token"; | |
| 26 | readonly role: SessionRole; | |
| 27 | readonly subject: string; | |
| 28 | readonly client: AuthClientMetadata; | |
| 29 | readonly expiresAt: DateTime.Utc; | |
| 30 | } | |
| 31 | ||
| 32 | export class AuthControlPlaneError extends Data.TaggedError("AuthControlPlaneError")<{ | |
| 33 | readonly message: string; | |
| 34 | readonly cause?: unknown; | |
| 35 | }> {} | |
| 36 | ||
| 37 | export interface AuthControlPlaneShape { | |
| 38 | readonly createPairingLink: (input?: { | |
| 39 | readonly ttl?: Duration.Duration; | |
| 40 | readonly label?: string; | |
| 41 | readonly role?: SessionRole; | |
| 42 | readonly subject?: string; | |
| 43 | }) => Effect.Effect<IssuedPairingLink, AuthControlPlaneError>; | |
| 44 | readonly listPairingLinks: (input?: { | |
| 45 | readonly role?: SessionRole; | |
| 46 | readonly excludeSubjects?: ReadonlyArray<string>; | |
| 47 | }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthControlPlaneError>; | |
| 48 | readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthControlPlaneError>; | |
| 49 | readonly issueSession: (input?: { | |
| 50 | readonly ttl?: Duration.Duration; | |
| 51 | readonly subject?: string; | |
| 52 | readonly role?: SessionRole; | |
| 53 | readonly label?: string; | |
| 54 | }) => Effect.Effect<IssuedBearerSession, AuthControlPlaneError>; | |
| 55 | readonly listSessions: () => Effect.Effect< | |
| 56 | ReadonlyArray<AuthClientSession>, | |
| 57 | AuthControlPlaneError | |
| 58 | >; | |
| 59 | readonly revokeSession: ( | |
| 60 | sessionId: AuthSessionId, | |
| 61 | ) => Effect.Effect<boolean, AuthControlPlaneError>; | |
| 62 | readonly revokeOtherSessionsExcept: ( | |
| 63 | sessionId: AuthSessionId, | |
| 64 | ) => Effect.Effect<number, AuthControlPlaneError>; | |
| 65 | } | |
| 66 | ||
| 67 | export class AuthControlPlane extends ServiceMap.Service<AuthControlPlane, AuthControlPlaneShape>()( | |
| 68 | "t3/AuthControlPlane", | |
| 69 | ) {} |
apps/server/src/auth/Services/BootstrapCredentialService.ts created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts"; | |
| 2 | import { Data, DateTime, Duration, ServiceMap } from "effect"; | |
| 3 | import type { Effect, Stream } from "effect"; | |
| 4 | ||
| 5 | export type BootstrapCredentialRole = "owner" | "client"; | |
| 6 | ||
| 7 | export interface BootstrapGrant { | |
| 8 | readonly method: ServerAuthBootstrapMethod; | |
| 9 | readonly role: BootstrapCredentialRole; | |
| 10 | readonly subject: string; | |
| 11 | readonly label?: string; | |
| 12 | readonly expiresAt: DateTime.DateTime; | |
| 13 | } | |
| 14 | ||
| 15 | export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{ | |
| 16 | readonly message: string; | |
| 17 | readonly status?: 401 | 500; | |
| 18 | readonly cause?: unknown; | |
| 19 | }> {} | |
| 20 | ||
| 21 | export interface IssuedBootstrapCredential { | |
| 22 | readonly id: string; | |
| 23 | readonly credential: string; | |
| 24 | readonly label?: string; | |
| 25 | readonly expiresAt: DateTime.Utc; | |
| 26 | } | |
| 27 | ||
| 28 | export type BootstrapCredentialChange = | |
| 29 | | { | |
| 30 | readonly type: "pairingLinkUpserted"; | |
| 31 | readonly pairingLink: AuthPairingLink; | |
| 32 | } | |
| 33 | | { | |
| 34 | readonly type: "pairingLinkRemoved"; | |
| 35 | readonly id: string; | |
| 36 | }; | |
| 37 | ||
| 38 | export interface BootstrapCredentialServiceShape { | |
| 39 | readonly issueOneTimeToken: (input?: { | |
| 40 | readonly ttl?: Duration.Duration; | |
| 41 | readonly role?: BootstrapCredentialRole; | |
| 42 | readonly subject?: string; | |
| 43 | readonly label?: string; | |
| 44 | }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialError>; | |
| 45 | readonly listActive: () => Effect.Effect< | |
| 46 | ReadonlyArray<AuthPairingLink>, | |
| 47 | BootstrapCredentialError | |
| 48 | >; | |
| 49 | readonly streamChanges: Stream.Stream<BootstrapCredentialChange>; | |
| 50 | readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialError>; | |
| 51 | readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | |
| 52 | } | |
| 53 | ||
| 54 | export class BootstrapCredentialService extends ServiceMap.Service< | |
| 55 | BootstrapCredentialService, | |
| 56 | BootstrapCredentialServiceShape | |
| 57 | >()("t3/auth/Services/BootstrapCredentialService") {} |
apps/server/src/auth/Services/ServerAuth.ts created+84| ... | ... | @@ -0,0 +1,84 @@ |
| 1 | import type { | |
| 2 | AuthBearerBootstrapResult, | |
| 3 | AuthBootstrapResult, | |
| 4 | AuthClientMetadata, | |
| 5 | AuthClientSession, | |
| 6 | AuthCreatePairingCredentialInput, | |
| 7 | AuthPairingLink, | |
| 8 | AuthPairingCredentialResult, | |
| 9 | AuthSessionId, | |
| 10 | AuthSessionState, | |
| 11 | ServerAuthDescriptor, | |
| 12 | ServerAuthSessionMethod, | |
| 13 | AuthWebSocketTokenResult, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { Data, DateTime, ServiceMap } from "effect"; | |
| 16 | import type { Effect } from "effect"; | |
| 17 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | |
| 18 | import type { SessionRole } from "./SessionCredentialService.ts"; | |
| 19 | ||
| 20 | export interface AuthenticatedSession { | |
| 21 | readonly sessionId: AuthSessionId; | |
| 22 | readonly subject: string; | |
| 23 | readonly method: ServerAuthSessionMethod; | |
| 24 | readonly role: SessionRole; | |
| 25 | readonly expiresAt?: DateTime.DateTime; | |
| 26 | } | |
| 27 | ||
| 28 | export class AuthError extends Data.TaggedError("AuthError")<{ | |
| 29 | readonly message: string; | |
| 30 | readonly status?: 400 | 401 | 403 | 500; | |
| 31 | readonly cause?: unknown; | |
| 32 | }> {} | |
| 33 | ||
| 34 | export interface ServerAuthShape { | |
| 35 | readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | |
| 36 | readonly getSessionState: ( | |
| 37 | request: HttpServerRequest.HttpServerRequest, | |
| 38 | ) => Effect.Effect<AuthSessionState, never>; | |
| 39 | readonly exchangeBootstrapCredential: ( | |
| 40 | credential: string, | |
| 41 | requestMetadata: AuthClientMetadata, | |
| 42 | ) => Effect.Effect< | |
| 43 | { | |
| 44 | readonly response: AuthBootstrapResult; | |
| 45 | readonly sessionToken: string; | |
| 46 | }, | |
| 47 | AuthError | |
| 48 | >; | |
| 49 | readonly exchangeBootstrapCredentialForBearerSession: ( | |
| 50 | credential: string, | |
| 51 | requestMetadata: AuthClientMetadata, | |
| 52 | ) => Effect.Effect<AuthBearerBootstrapResult, AuthError>; | |
| 53 | readonly issuePairingCredential: ( | |
| 54 | input?: AuthCreatePairingCredentialInput & { | |
| 55 | readonly role?: SessionRole; | |
| 56 | }, | |
| 57 | ) => Effect.Effect<AuthPairingCredentialResult, AuthError>; | |
| 58 | readonly listPairingLinks: () => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthError>; | |
| 59 | readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthError>; | |
| 60 | readonly listClientSessions: ( | |
| 61 | currentSessionId: AuthSessionId, | |
| 62 | ) => Effect.Effect<ReadonlyArray<AuthClientSession>, AuthError>; | |
| 63 | readonly revokeClientSession: ( | |
| 64 | currentSessionId: AuthSessionId, | |
| 65 | targetSessionId: AuthSessionId, | |
| 66 | ) => Effect.Effect<boolean, AuthError>; | |
| 67 | readonly revokeOtherClientSessions: ( | |
| 68 | currentSessionId: AuthSessionId, | |
| 69 | ) => Effect.Effect<number, AuthError>; | |
| 70 | readonly authenticateHttpRequest: ( | |
| 71 | request: HttpServerRequest.HttpServerRequest, | |
| 72 | ) => Effect.Effect<AuthenticatedSession, AuthError>; | |
| 73 | readonly authenticateWebSocketUpgrade: ( | |
| 74 | request: HttpServerRequest.HttpServerRequest, | |
| 75 | ) => Effect.Effect<AuthenticatedSession, AuthError>; | |
| 76 | readonly issueWebSocketToken: ( | |
| 77 | session: AuthenticatedSession, | |
| 78 | ) => Effect.Effect<AuthWebSocketTokenResult, AuthError>; | |
| 79 | readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect<string, AuthError>; | |
| 80 | } | |
| 81 | ||
| 82 | export class ServerAuth extends ServiceMap.Service<ServerAuth, ServerAuthShape>()( | |
| 83 | "t3/auth/Services/ServerAuth", | |
| 84 | ) {} |
apps/server/src/auth/Services/ServerAuthPolicy.ts created+11| ... | ... | @@ -0,0 +1,11 @@ |
| 1 | import type { ServerAuthDescriptor } from "@t3tools/contracts"; | |
| 2 | import { ServiceMap } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | export interface ServerAuthPolicyShape { | |
| 6 | readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | |
| 7 | } | |
| 8 | ||
| 9 | export class ServerAuthPolicy extends ServiceMap.Service<ServerAuthPolicy, ServerAuthPolicyShape>()( | |
| 10 | "t3/auth/Services/ServerAuthPolicy", | |
| 11 | ) {} |
apps/server/src/auth/Services/ServerSecretStore.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | import { Data, ServiceMap } from "effect"; | |
| 2 | import type { Effect } from "effect"; | |
| 3 | ||
| 4 | export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ | |
| 5 | readonly message: string; | |
| 6 | readonly cause?: unknown; | |
| 7 | }> {} | |
| 8 | ||
| 9 | export interface ServerSecretStoreShape { | |
| 10 | readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>; | |
| 11 | readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | |
| 12 | readonly getOrCreateRandom: ( | |
| 13 | name: string, | |
| 14 | bytes: number, | |
| 15 | ) => Effect.Effect<Uint8Array, SecretStoreError>; | |
| 16 | readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>; | |
| 17 | } | |
| 18 | ||
| 19 | export class ServerSecretStore extends ServiceMap.Service< | |
| 20 | ServerSecretStore, | |
| 21 | ServerSecretStoreShape | |
| 22 | >()("t3/auth/Services/ServerSecretStore") {} |
apps/server/src/auth/Services/SessionCredentialService.ts created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import type { | |
| 2 | AuthClientMetadata, | |
| 3 | AuthClientSession, | |
| 4 | AuthSessionId, | |
| 5 | ServerAuthSessionMethod, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { Data, DateTime, Duration, ServiceMap } from "effect"; | |
| 8 | import type { Effect, Stream } from "effect"; | |
| 9 | ||
| 10 | export type SessionRole = "owner" | "client"; | |
| 11 | ||
| 12 | export interface IssuedSession { | |
| 13 | readonly sessionId: AuthSessionId; | |
| 14 | readonly token: string; | |
| 15 | readonly method: ServerAuthSessionMethod; | |
| 16 | readonly client: AuthClientMetadata; | |
| 17 | readonly expiresAt: DateTime.DateTime; | |
| 18 | readonly role: SessionRole; | |
| 19 | } | |
| 20 | ||
| 21 | export interface VerifiedSession { | |
| 22 | readonly sessionId: AuthSessionId; | |
| 23 | readonly token: string; | |
| 24 | readonly method: ServerAuthSessionMethod; | |
| 25 | readonly client: AuthClientMetadata; | |
| 26 | readonly expiresAt?: DateTime.DateTime; | |
| 27 | readonly subject: string; | |
| 28 | readonly role: SessionRole; | |
| 29 | } | |
| 30 | ||
| 31 | export type SessionCredentialChange = | |
| 32 | | { | |
| 33 | readonly type: "clientUpserted"; | |
| 34 | readonly clientSession: AuthClientSession; | |
| 35 | } | |
| 36 | | { | |
| 37 | readonly type: "clientRemoved"; | |
| 38 | readonly sessionId: AuthSessionId; | |
| 39 | }; | |
| 40 | ||
| 41 | export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{ | |
| 42 | readonly message: string; | |
| 43 | readonly cause?: unknown; | |
| 44 | }> {} | |
| 45 | ||
| 46 | export interface SessionCredentialServiceShape { | |
| 47 | readonly cookieName: string; | |
| 48 | readonly issue: (input?: { | |
| 49 | readonly ttl?: Duration.Duration; | |
| 50 | readonly subject?: string; | |
| 51 | readonly method?: ServerAuthSessionMethod; | |
| 52 | readonly role?: SessionRole; | |
| 53 | readonly client?: AuthClientMetadata; | |
| 54 | }) => Effect.Effect<IssuedSession, SessionCredentialError>; | |
| 55 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | |
| 56 | readonly issueWebSocketToken: ( | |
| 57 | sessionId: AuthSessionId, | |
| 58 | input?: { | |
| 59 | readonly ttl?: Duration.Duration; | |
| 60 | }, | |
| 61 | ) => Effect.Effect< | |
| 62 | { | |
| 63 | readonly token: string; | |
| 64 | readonly expiresAt: DateTime.DateTime; | |
| 65 | }, | |
| 66 | SessionCredentialError | |
| 67 | >; | |
| 68 | readonly verifyWebSocketToken: ( | |
| 69 | token: string, | |
| 70 | ) => Effect.Effect<VerifiedSession, SessionCredentialError>; | |
| 71 | readonly listActive: () => Effect.Effect< | |
| 72 | ReadonlyArray<AuthClientSession>, | |
| 73 | SessionCredentialError | |
| 74 | >; | |
| 75 | readonly streamChanges: Stream.Stream<SessionCredentialChange>; | |
| 76 | readonly revoke: (sessionId: AuthSessionId) => Effect.Effect<boolean, SessionCredentialError>; | |
| 77 | readonly revokeAllExcept: ( | |
| 78 | sessionId: AuthSessionId, | |
| 79 | ) => Effect.Effect<number, SessionCredentialError>; | |
| 80 | readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | |
| 81 | readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | |
| 82 | } | |
| 83 | ||
| 84 | export class SessionCredentialService extends ServiceMap.Service< | |
| 85 | SessionCredentialService, | |
| 86 | SessionCredentialServiceShape | |
| 87 | >()("t3/auth/Services/SessionCredentialService") {} |
apps/server/src/auth/http.ts created+254| ... | ... | @@ -0,0 +1,254 @@ |
| 1 | import { | |
| 2 | type AuthBearerBootstrapResult, | |
| 3 | AuthBootstrapInput, | |
| 4 | AuthCreatePairingCredentialInput, | |
| 5 | AuthRevokeClientSessionInput, | |
| 6 | AuthRevokePairingLinkInput, | |
| 7 | type AuthWebSocketTokenResult, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { DateTime, Effect, Schema } from "effect"; | |
| 10 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 11 | ||
| 12 | import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; | |
| 13 | import { SessionCredentialService } from "./Services/SessionCredentialService.ts"; | |
| 14 | import { deriveAuthClientMetadata } from "./utils.ts"; | |
| 15 | ||
| 16 | export const respondToAuthError = (error: AuthError) => | |
| 17 | Effect.gen(function* () { | |
| 18 | if ((error.status ?? 500) >= 500) { | |
| 19 | yield* Effect.logError("auth route failed", { | |
| 20 | message: error.message, | |
| 21 | cause: error.cause, | |
| 22 | }); | |
| 23 | } | |
| 24 | return HttpServerResponse.jsonUnsafe( | |
| 25 | { | |
| 26 | error: error.message, | |
| 27 | }, | |
| 28 | { status: error.status ?? 500 }, | |
| 29 | ); | |
| 30 | }); | |
| 31 | ||
| 32 | export const authSessionRouteLayer = HttpRouter.add( | |
| 33 | "GET", | |
| 34 | "/api/auth/session", | |
| 35 | Effect.gen(function* () { | |
| 36 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 37 | const serverAuth = yield* ServerAuth; | |
| 38 | const session = yield* serverAuth.getSessionState(request); | |
| 39 | return HttpServerResponse.jsonUnsafe(session, { status: 200 }); | |
| 40 | }), | |
| 41 | ); | |
| 42 | ||
| 43 | const PairingCredentialRequestHeaders = Schema.Struct({ | |
| 44 | "content-length": Schema.optionalKey(Schema.String), | |
| 45 | "content-type": Schema.optionalKey(Schema.String), | |
| 46 | "transfer-encoding": Schema.optionalKey(Schema.String), | |
| 47 | }); | |
| 48 | ||
| 49 | function hasRequestBody(headers: typeof PairingCredentialRequestHeaders.Type) { | |
| 50 | const contentLengthHeader = headers["content-length"]; | |
| 51 | if (typeof contentLengthHeader === "string") { | |
| 52 | const contentLength = Number.parseInt(contentLengthHeader, 10); | |
| 53 | if (Number.isFinite(contentLength)) { | |
| 54 | return contentLength > 0; | |
| 55 | } | |
| 56 | } | |
| 57 | return typeof headers["transfer-encoding"] === "string"; | |
| 58 | } | |
| 59 | ||
| 60 | export const authBootstrapRouteLayer = HttpRouter.add( | |
| 61 | "POST", | |
| 62 | "/api/auth/bootstrap", | |
| 63 | Effect.gen(function* () { | |
| 64 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 65 | const serverAuth = yield* ServerAuth; | |
| 66 | const sessions = yield* SessionCredentialService; | |
| 67 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( | |
| 68 | Effect.mapError( | |
| 69 | (cause) => | |
| 70 | new AuthError({ | |
| 71 | message: "Invalid bootstrap payload.", | |
| 72 | status: 400, | |
| 73 | cause, | |
| 74 | }), | |
| 75 | ), | |
| 76 | ); | |
| 77 | const result = yield* serverAuth.exchangeBootstrapCredential( | |
| 78 | payload.credential, | |
| 79 | deriveAuthClientMetadata({ request }), | |
| 80 | ); | |
| 81 | ||
| 82 | return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe( | |
| 83 | HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, { | |
| 84 | expires: DateTime.toDate(result.response.expiresAt), | |
| 85 | httpOnly: true, | |
| 86 | path: "/", | |
| 87 | sameSite: "lax", | |
| 88 | }), | |
| 89 | ); | |
| 90 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 91 | ); | |
| 92 | ||
| 93 | export const authBearerBootstrapRouteLayer = HttpRouter.add( | |
| 94 | "POST", | |
| 95 | "/api/auth/bootstrap/bearer", | |
| 96 | Effect.gen(function* () { | |
| 97 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 98 | const serverAuth = yield* ServerAuth; | |
| 99 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( | |
| 100 | Effect.mapError( | |
| 101 | (cause) => | |
| 102 | new AuthError({ | |
| 103 | message: "Invalid bootstrap payload.", | |
| 104 | status: 400, | |
| 105 | cause, | |
| 106 | }), | |
| 107 | ), | |
| 108 | ); | |
| 109 | const result = yield* serverAuth.exchangeBootstrapCredentialForBearerSession( | |
| 110 | payload.credential, | |
| 111 | deriveAuthClientMetadata({ request }), | |
| 112 | ); | |
| 113 | return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, { | |
| 114 | status: 200, | |
| 115 | }); | |
| 116 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 117 | ); | |
| 118 | ||
| 119 | export const authWebSocketTokenRouteLayer = HttpRouter.add( | |
| 120 | "POST", | |
| 121 | "/api/auth/ws-token", | |
| 122 | Effect.gen(function* () { | |
| 123 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 124 | const serverAuth = yield* ServerAuth; | |
| 125 | const session = yield* serverAuth.authenticateHttpRequest(request); | |
| 126 | const result = yield* serverAuth.issueWebSocketToken(session); | |
| 127 | return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, { | |
| 128 | status: 200, | |
| 129 | }); | |
| 130 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 131 | ); | |
| 132 | ||
| 133 | export const authPairingCredentialRouteLayer = HttpRouter.add( | |
| 134 | "POST", | |
| 135 | "/api/auth/pairing-token", | |
| 136 | Effect.gen(function* () { | |
| 137 | const serverAuth = yield* ServerAuth; | |
| 138 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 139 | const session = yield* serverAuth.authenticateHttpRequest(request); | |
| 140 | if (session.role !== "owner") { | |
| 141 | return yield* new AuthError({ | |
| 142 | message: "Only owner sessions can create pairing credentials.", | |
| 143 | status: 403, | |
| 144 | }); | |
| 145 | } | |
| 146 | const headers = yield* HttpServerRequest.schemaHeaders(PairingCredentialRequestHeaders).pipe( | |
| 147 | Effect.mapError( | |
| 148 | (cause) => | |
| 149 | new AuthError({ | |
| 150 | message: "Invalid pairing credential request headers.", | |
| 151 | status: 400, | |
| 152 | cause, | |
| 153 | }), | |
| 154 | ), | |
| 155 | ); | |
| 156 | const payload = hasRequestBody(headers) | |
| 157 | ? yield* HttpServerRequest.schemaBodyJson(AuthCreatePairingCredentialInput).pipe( | |
| 158 | Effect.mapError( | |
| 159 | (cause) => | |
| 160 | new AuthError({ | |
| 161 | message: "Invalid pairing credential payload.", | |
| 162 | status: 400, | |
| 163 | cause, | |
| 164 | }), | |
| 165 | ), | |
| 166 | ) | |
| 167 | : {}; | |
| 168 | const result = yield* serverAuth.issuePairingCredential(payload); | |
| 169 | return HttpServerResponse.jsonUnsafe(result, { status: 200 }); | |
| 170 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 171 | ); | |
| 172 | ||
| 173 | const authenticateOwnerSession = Effect.gen(function* () { | |
| 174 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 175 | const serverAuth = yield* ServerAuth; | |
| 176 | const session = yield* serverAuth.authenticateHttpRequest(request); | |
| 177 | if (session.role !== "owner") { | |
| 178 | return yield* new AuthError({ | |
| 179 | message: "Only owner sessions can manage network access.", | |
| 180 | status: 403, | |
| 181 | }); | |
| 182 | } | |
| 183 | return { serverAuth, session } as const; | |
| 184 | }); | |
| 185 | ||
| 186 | export const authPairingLinksRouteLayer = HttpRouter.add( | |
| 187 | "GET", | |
| 188 | "/api/auth/pairing-links", | |
| 189 | Effect.gen(function* () { | |
| 190 | const { serverAuth } = yield* authenticateOwnerSession; | |
| 191 | const pairingLinks = yield* serverAuth.listPairingLinks(); | |
| 192 | return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 }); | |
| 193 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 194 | ); | |
| 195 | ||
| 196 | export const authPairingLinksRevokeRouteLayer = HttpRouter.add( | |
| 197 | "POST", | |
| 198 | "/api/auth/pairing-links/revoke", | |
| 199 | Effect.gen(function* () { | |
| 200 | const { serverAuth } = yield* authenticateOwnerSession; | |
| 201 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokePairingLinkInput).pipe( | |
| 202 | Effect.mapError( | |
| 203 | (cause) => | |
| 204 | new AuthError({ | |
| 205 | message: "Invalid revoke pairing link payload.", | |
| 206 | status: 400, | |
| 207 | cause, | |
| 208 | }), | |
| 209 | ), | |
| 210 | ); | |
| 211 | const revoked = yield* serverAuth.revokePairingLink(payload.id); | |
| 212 | return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); | |
| 213 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 214 | ); | |
| 215 | ||
| 216 | export const authClientsRouteLayer = HttpRouter.add( | |
| 217 | "GET", | |
| 218 | "/api/auth/clients", | |
| 219 | Effect.gen(function* () { | |
| 220 | const { serverAuth, session } = yield* authenticateOwnerSession; | |
| 221 | const clients = yield* serverAuth.listClientSessions(session.sessionId); | |
| 222 | return HttpServerResponse.jsonUnsafe(clients, { status: 200 }); | |
| 223 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 224 | ); | |
| 225 | ||
| 226 | export const authClientsRevokeRouteLayer = HttpRouter.add( | |
| 227 | "POST", | |
| 228 | "/api/auth/clients/revoke", | |
| 229 | Effect.gen(function* () { | |
| 230 | const { serverAuth, session } = yield* authenticateOwnerSession; | |
| 231 | const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe( | |
| 232 | Effect.mapError( | |
| 233 | (cause) => | |
| 234 | new AuthError({ | |
| 235 | message: "Invalid revoke client payload.", | |
| 236 | status: 400, | |
| 237 | cause, | |
| 238 | }), | |
| 239 | ), | |
| 240 | ); | |
| 241 | const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId); | |
| 242 | return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); | |
| 243 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 244 | ); | |
| 245 | ||
| 246 | export const authClientsRevokeOthersRouteLayer = HttpRouter.add( | |
| 247 | "POST", | |
| 248 | "/api/auth/clients/revoke-others", | |
| 249 | Effect.gen(function* () { | |
| 250 | const { serverAuth, session } = yield* authenticateOwnerSession; | |
| 251 | const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); | |
| 252 | return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 }); | |
| 253 | }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 254 | ); |
apps/server/src/auth/utils.test.ts created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { deriveAuthClientMetadata } from "./utils"; | |
| 4 | ||
| 5 | describe("deriveAuthClientMetadata", () => { | |
| 6 | it("labels Electron user agents as Electron instead of Chrome", () => { | |
| 7 | const metadata = deriveAuthClientMetadata({ | |
| 8 | request: { | |
| 9 | headers: { | |
| 10 | "user-agent": | |
| 11 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) t3code/0.0.15 Chrome/136.0.7103.93 Electron/36.3.2 Safari/537.36", | |
| 12 | }, | |
| 13 | source: { | |
| 14 | remoteAddress: "::ffff:127.0.0.1", | |
| 15 | }, | |
| 16 | } as never, | |
| 17 | }); | |
| 18 | ||
| 19 | expect(metadata).toMatchObject({ | |
| 20 | browser: "Electron", | |
| 21 | deviceType: "desktop", | |
| 22 | ipAddress: "127.0.0.1", | |
| 23 | os: "macOS", | |
| 24 | }); | |
| 25 | }); | |
| 26 | }); |
apps/server/src/auth/utils.ts created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts"; | |
| 2 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | |
| 3 | import * as Crypto from "node:crypto"; | |
| 4 | ||
| 5 | export const SESSION_COOKIE_NAME = "t3_session"; | |
| 6 | ||
| 7 | export function base64UrlEncode(input: string | Uint8Array): string { | |
| 8 | const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); | |
| 9 | return buffer.toString("base64url"); | |
| 10 | } | |
| 11 | ||
| 12 | export function base64UrlDecodeUtf8(input: string): string { | |
| 13 | return Buffer.from(input, "base64url").toString("utf8"); | |
| 14 | } | |
| 15 | ||
| 16 | export function signPayload(payload: string, secret: Uint8Array): string { | |
| 17 | return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); | |
| 18 | } | |
| 19 | ||
| 20 | export function timingSafeEqualBase64Url(left: string, right: string): boolean { | |
| 21 | const leftBuffer = Buffer.from(left, "base64url"); | |
| 22 | const rightBuffer = Buffer.from(right, "base64url"); | |
| 23 | if (leftBuffer.length !== rightBuffer.length) { | |
| 24 | return false; | |
| 25 | } | |
| 26 | return Crypto.timingSafeEqual(leftBuffer, rightBuffer); | |
| 27 | } | |
| 28 | ||
| 29 | function normalizeNonEmptyString(value: string | null | undefined): string | undefined { | |
| 30 | if (typeof value !== "string") { | |
| 31 | return undefined; | |
| 32 | } | |
| 33 | const trimmed = value.trim(); | |
| 34 | return trimmed.length > 0 ? trimmed : undefined; | |
| 35 | } | |
| 36 | ||
| 37 | function normalizeIpAddress(value: string | null | undefined): string | undefined { | |
| 38 | const normalized = normalizeNonEmptyString(value); | |
| 39 | if (!normalized) { | |
| 40 | return undefined; | |
| 41 | } | |
| 42 | return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized; | |
| 43 | } | |
| 44 | ||
| 45 | function inferDeviceType(userAgent: string | undefined): AuthClientMetadataDeviceType { | |
| 46 | if (!userAgent) { | |
| 47 | return "unknown"; | |
| 48 | } | |
| 49 | ||
| 50 | const normalized = userAgent.toLowerCase(); | |
| 51 | if (/bot|crawler|spider|slurp|curl|wget/.test(normalized)) { | |
| 52 | return "bot"; | |
| 53 | } | |
| 54 | if (/ipad|tablet/.test(normalized)) { | |
| 55 | return "tablet"; | |
| 56 | } | |
| 57 | if (/iphone|android.+mobile|mobile/.test(normalized)) { | |
| 58 | return "mobile"; | |
| 59 | } | |
| 60 | return "desktop"; | |
| 61 | } | |
| 62 | ||
| 63 | function inferBrowser(userAgent: string | undefined): string | undefined { | |
| 64 | if (!userAgent) { | |
| 65 | return undefined; | |
| 66 | } | |
| 67 | const normalized = userAgent.toLowerCase(); | |
| 68 | if (/edg\//.test(normalized)) return "Edge"; | |
| 69 | if (/opr\//.test(normalized)) return "Opera"; | |
| 70 | if (/firefox\//.test(normalized)) return "Firefox"; | |
| 71 | if (/electron\//.test(normalized)) return "Electron"; | |
| 72 | if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome"; | |
| 73 | if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari"; | |
| 74 | return undefined; | |
| 75 | } | |
| 76 | ||
| 77 | function inferOs(userAgent: string | undefined): string | undefined { | |
| 78 | if (!userAgent) { | |
| 79 | return undefined; | |
| 80 | } | |
| 81 | const normalized = userAgent.toLowerCase(); | |
| 82 | if (/iphone|ipad|ipod/.test(normalized)) return "iOS"; | |
| 83 | if (/android/.test(normalized)) return "Android"; | |
| 84 | if (/mac os x|macintosh/.test(normalized)) return "macOS"; | |
| 85 | if (/windows nt/.test(normalized)) return "Windows"; | |
| 86 | if (/linux/.test(normalized)) return "Linux"; | |
| 87 | return undefined; | |
| 88 | } | |
| 89 | ||
| 90 | function readRemoteAddressFromSource(source: unknown): string | undefined { | |
| 91 | if (!source || typeof source !== "object") { | |
| 92 | return undefined; | |
| 93 | } | |
| 94 | ||
| 95 | const candidate = source as { | |
| 96 | readonly remoteAddress?: string | null; | |
| 97 | readonly socket?: { | |
| 98 | readonly remoteAddress?: string | null; | |
| 99 | }; | |
| 100 | }; | |
| 101 | ||
| 102 | return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress); | |
| 103 | } | |
| 104 | ||
| 105 | export function deriveAuthClientMetadata(input: { | |
| 106 | readonly request: HttpServerRequest.HttpServerRequest; | |
| 107 | readonly label?: string; | |
| 108 | }): AuthClientMetadata { | |
| 109 | const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]); | |
| 110 | const ipAddress = readRemoteAddressFromSource(input.request.source); | |
| 111 | const os = inferOs(userAgent); | |
| 112 | const browser = inferBrowser(userAgent); | |
| 113 | return { | |
| 114 | ...(input.label ? { label: input.label } : {}), | |
| 115 | ...(ipAddress ? { ipAddress } : {}), | |
| 116 | ...(userAgent ? { userAgent } : {}), | |
| 117 | deviceType: inferDeviceType(userAgent), | |
| 118 | ...(os ? { os } : {}), | |
| 119 | ...(browser ? { browser } : {}), | |
| 120 | }; | |
| 121 | } |
apps/server/src/cli-config.test.ts+5-15| ... | ... | @@ -43,7 +43,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 43 | 43 | cwd: Option.none(), |
| 44 | 44 | devUrl: Option.none(), |
| 45 | 45 | noBrowser: Option.none(), |
| 46 | authToken: Option.none(), | |
| 47 | 46 | bootstrapFd: Option.none(), |
| 48 | 47 | autoBootstrapProjectFromCwd: Option.none(), |
| 49 | 48 | logWebSocketEvents: Option.none(), |
| ... | ... | @@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 62 | 61 | T3CODE_HOME: baseDir, |
| 63 | 62 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", |
| 64 | 63 | T3CODE_NO_BROWSER: "true", |
| 65 | T3CODE_AUTH_TOKEN: "env-token", | |
| 66 | 64 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", |
| 67 | 65 | T3CODE_LOG_WS_EVENTS: "true", |
| 68 | 66 | }, |
| ... | ... | @@ -85,7 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 85 | 83 | staticDir: undefined, |
| 86 | 84 | devUrl: new URL("http://127.0.0.1:5173"), |
| 87 | 85 | noBrowser: true, |
| 88 | authToken: "env-token", | |
| 86 | desktopBootstrapToken: undefined, | |
| 89 | 87 | autoBootstrapProjectFromCwd: false, |
| 90 | 88 | logWebSocketEvents: true, |
| 91 | 89 | }); |
| ... | ... | @@ -106,7 +104,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 106 | 104 | cwd: Option.none(), |
| 107 | 105 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), |
| 108 | 106 | noBrowser: Option.some(true), |
| 109 | authToken: Option.some("flag-token"), | |
| 110 | 107 | bootstrapFd: Option.none(), |
| 111 | 108 | autoBootstrapProjectFromCwd: Option.some(true), |
| 112 | 109 | logWebSocketEvents: Option.some(true), |
| ... | ... | @@ -125,7 +122,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 125 | 122 | T3CODE_HOME: join(os.tmpdir(), "ignored-base"), |
| 126 | 123 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", |
| 127 | 124 | T3CODE_NO_BROWSER: "false", |
| 128 | T3CODE_AUTH_TOKEN: "ignored-token", | |
| 129 | 125 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", |
| 130 | 126 | T3CODE_LOG_WS_EVENTS: "false", |
| 131 | 127 | }, |
| ... | ... | @@ -148,7 +144,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 148 | 144 | staticDir: undefined, |
| 149 | 145 | devUrl: new URL("http://127.0.0.1:4173"), |
| 150 | 146 | noBrowser: true, |
| 151 | authToken: "flag-token", | |
| 147 | desktopBootstrapToken: undefined, | |
| 152 | 148 | autoBootstrapProjectFromCwd: true, |
| 153 | 149 | logWebSocketEvents: true, |
| 154 | 150 | }); |
| ... | ... | @@ -166,7 +162,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 166 | 162 | t3Home: baseDir, |
| 167 | 163 | devUrl: "http://127.0.0.1:5173", |
| 168 | 164 | noBrowser: true, |
| 169 | authToken: "bootstrap-token", | |
| 170 | 165 | autoBootstrapProjectFromCwd: false, |
| 171 | 166 | logWebSocketEvents: true, |
| 172 | 167 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| ... | ... | @@ -183,7 +178,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 183 | 178 | cwd: Option.none(), |
| 184 | 179 | devUrl: Option.none(), |
| 185 | 180 | noBrowser: Option.none(), |
| 186 | authToken: Option.none(), | |
| 187 | 181 | bootstrapFd: Option.none(), |
| 188 | 182 | autoBootstrapProjectFromCwd: Option.none(), |
| 189 | 183 | logWebSocketEvents: Option.none(), |
| ... | ... | @@ -218,7 +212,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 218 | 212 | staticDir: undefined, |
| 219 | 213 | devUrl: new URL("http://127.0.0.1:5173"), |
| 220 | 214 | noBrowser: true, |
| 221 | authToken: "bootstrap-token", | |
| 215 | desktopBootstrapToken: undefined, | |
| 222 | 216 | autoBootstrapProjectFromCwd: false, |
| 223 | 217 | logWebSocketEvents: true, |
| 224 | 218 | }); |
| ... | ... | @@ -242,7 +236,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 242 | 236 | cwd: Option.some(customCwd), |
| 243 | 237 | devUrl: Option.some(new URL("http://127.0.0.1:5173")), |
| 244 | 238 | noBrowser: Option.none(), |
| 245 | authToken: Option.none(), | |
| 246 | 239 | bootstrapFd: Option.none(), |
| 247 | 240 | autoBootstrapProjectFromCwd: Option.none(), |
| 248 | 241 | logWebSocketEvents: Option.none(), |
| ... | ... | @@ -285,7 +278,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 285 | 278 | t3Home: "/tmp/t3-bootstrap-home", |
| 286 | 279 | devUrl: "http://127.0.0.1:5173", |
| 287 | 280 | noBrowser: false, |
| 288 | authToken: "bootstrap-token", | |
| 289 | 281 | autoBootstrapProjectFromCwd: false, |
| 290 | 282 | logWebSocketEvents: false, |
| 291 | 283 | }); |
| ... | ... | @@ -300,7 +292,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 300 | 292 | cwd: Option.none(), |
| 301 | 293 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), |
| 302 | 294 | noBrowser: Option.none(), |
| 303 | authToken: Option.some("flag-token"), | |
| 304 | 295 | bootstrapFd: Option.none(), |
| 305 | 296 | autoBootstrapProjectFromCwd: Option.none(), |
| 306 | 297 | logWebSocketEvents: Option.none(), |
| ... | ... | @@ -338,7 +329,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 338 | 329 | staticDir: undefined, |
| 339 | 330 | devUrl: new URL("http://127.0.0.1:4173"), |
| 340 | 331 | noBrowser: true, |
| 341 | authToken: "flag-token", | |
| 332 | desktopBootstrapToken: undefined, | |
| 342 | 333 | autoBootstrapProjectFromCwd: true, |
| 343 | 334 | logWebSocketEvents: true, |
| 344 | 335 | }); |
| ... | ... | @@ -371,7 +362,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 371 | 362 | cwd: Option.none(), |
| 372 | 363 | devUrl: Option.none(), |
| 373 | 364 | noBrowser: Option.none(), |
| 374 | authToken: Option.none(), | |
| 375 | 365 | bootstrapFd: Option.none(), |
| 376 | 366 | autoBootstrapProjectFromCwd: Option.none(), |
| 377 | 367 | logWebSocketEvents: Option.none(), |
| ... | ... | @@ -402,7 +392,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 402 | 392 | staticDir: resolved.staticDir, |
| 403 | 393 | devUrl: undefined, |
| 404 | 394 | noBrowser: true, |
| 405 | authToken: undefined, | |
| 395 | desktopBootstrapToken: undefined, | |
| 406 | 396 | autoBootstrapProjectFromCwd: false, |
| 407 | 397 | logWebSocketEvents: false, |
| 408 | 398 | }); |
apps/server/src/cli.test.ts+103-7| ... | ... | @@ -1,28 +1,41 @@ |
| 1 | import { mkdtempSync } from "node:fs"; | |
| 2 | import { tmpdir } from "node:os"; | |
| 3 | import { join } from "node:path"; | |
| 4 | ||
| 1 | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 6 | import { NetService } from "@t3tools/shared/Net"; |
| 3 | 7 | import { assert, it } from "@effect/vitest"; |
| 4 | 8 | import * as Effect from "effect/Effect"; |
| 5 | 9 | import * as Layer from "effect/Layer"; |
| 6 | 10 | import * as CliError from "effect/unstable/cli/CliError"; |
| 11 | import * as TestConsole from "effect/testing/TestConsole"; | |
| 7 | 12 | import { Command } from "effect/unstable/cli"; |
| 8 | 13 | |
| 9 | 14 | import { cli } from "./cli.ts"; |
| 10 | 15 | |
| 11 | 16 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 12 | 17 | |
| 18 | const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args); | |
| 19 | const runCliWithRuntime = (args: ReadonlyArray<string>) => | |
| 20 | runCli(args).pipe(Effect.provide(CliRuntimeLayer)); | |
| 21 | ||
| 22 | const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 23 | Effect.gen(function* () { | |
| 24 | const result = yield* effect; | |
| 25 | const output = | |
| 26 | (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ?? | |
| 27 | ""; | |
| 28 | return { result, output }; | |
| 29 | }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))); | |
| 30 | ||
| 13 | 31 | it.layer(NodeServices.layer)("cli log-level parsing", (it) => { |
| 14 | 32 | it.effect("accepts the built-in lowercase log-level flag values", () => |
| 15 | Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe( | |
| 16 | Effect.provide(CliRuntimeLayer), | |
| 17 | ), | |
| 33 | runCliWithRuntime(["--log-level", "debug", "--version"]), | |
| 18 | 34 | ); |
| 19 | 35 | |
| 20 | 36 | it.effect("rejects invalid log-level casing before launching the server", () => |
| 21 | 37 | Effect.gen(function* () { |
| 22 | const error = yield* Command.runWith(cli, { version: "0.0.0" })([ | |
| 23 | "--log-level", | |
| 24 | "Debug", | |
| 25 | ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip); | |
| 38 | const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip); | |
| 26 | 39 | |
| 27 | 40 | if (!CliError.isCliError(error)) { |
| 28 | 41 | assert.fail(`Expected CliError, got ${String(error)}`); |
| ... | ... | @@ -34,4 +47,87 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { |
| 34 | 47 | assert.equal(error.value, "Debug"); |
| 35 | 48 | }), |
| 36 | 49 | ); |
| 50 | ||
| 51 | it.effect("executes auth pairing subcommands and redacts secrets from list output", () => | |
| 52 | Effect.gen(function* () { | |
| 53 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-")); | |
| 54 | ||
| 55 | const createdOutput = yield* captureStdout( | |
| 56 | runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]), | |
| 57 | ); | |
| 58 | const created = JSON.parse(createdOutput.output) as { | |
| 59 | readonly id: string; | |
| 60 | readonly credential: string; | |
| 61 | }; | |
| 62 | const listedOutput = yield* captureStdout( | |
| 63 | runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), | |
| 64 | ); | |
| 65 | const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ | |
| 66 | readonly id: string; | |
| 67 | readonly credential?: string; | |
| 68 | }>; | |
| 69 | ||
| 70 | assert.equal(typeof created.id, "string"); | |
| 71 | assert.equal(typeof created.credential, "string"); | |
| 72 | assert.equal(created.credential.length > 0, true); | |
| 73 | assert.equal(listed.length, 1); | |
| 74 | assert.equal(listed[0]?.id, created.id); | |
| 75 | assert.equal("credential" in (listed[0] ?? {}), false); | |
| 76 | }), | |
| 77 | ); | |
| 78 | ||
| 79 | it.effect("executes auth session subcommands and redacts secrets from list output", () => | |
| 80 | Effect.gen(function* () { | |
| 81 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-")); | |
| 82 | ||
| 83 | const issuedOutput = yield* captureStdout( | |
| 84 | runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]), | |
| 85 | ); | |
| 86 | const issued = JSON.parse(issuedOutput.output) as { | |
| 87 | readonly sessionId: string; | |
| 88 | readonly token: string; | |
| 89 | readonly role: string; | |
| 90 | }; | |
| 91 | const listedOutput = yield* captureStdout( | |
| 92 | runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), | |
| 93 | ); | |
| 94 | const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ | |
| 95 | readonly sessionId: string; | |
| 96 | readonly token?: string; | |
| 97 | readonly role: string; | |
| 98 | }>; | |
| 99 | ||
| 100 | assert.equal(typeof issued.sessionId, "string"); | |
| 101 | assert.equal(typeof issued.token, "string"); | |
| 102 | assert.equal(issued.role, "owner"); | |
| 103 | assert.equal(listed.length, 1); | |
| 104 | assert.equal(listed[0]?.sessionId, issued.sessionId); | |
| 105 | assert.equal(listed[0]?.role, "owner"); | |
| 106 | assert.equal("token" in (listed[0] ?? {}), false); | |
| 107 | }), | |
| 108 | ); | |
| 109 | ||
| 110 | it.effect("rejects invalid ttl values before running auth commands", () => | |
| 111 | Effect.gen(function* () { | |
| 112 | const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe( | |
| 113 | Effect.flip, | |
| 114 | ); | |
| 115 | ||
| 116 | if (!CliError.isCliError(error)) { | |
| 117 | assert.fail(`Expected CliError, got ${String(error)}`); | |
| 118 | } | |
| 119 | if (error._tag !== "ShowHelp") { | |
| 120 | assert.fail(`Expected ShowHelp, got ${error._tag}`); | |
| 121 | } | |
| 122 | assert.deepEqual(error.commandPath, ["t3", "auth", "pairing", "create"]); | |
| 123 | const ttlError = error.errors[0] as CliError.CliError | undefined; | |
| 124 | if (!ttlError || ttlError._tag !== "InvalidValue") { | |
| 125 | assert.fail(`Expected InvalidValue, got ${String(ttlError?._tag)}`); | |
| 126 | } | |
| 127 | assert.equal(ttlError.option, "ttl"); | |
| 128 | assert.equal(ttlError.value, "soon"); | |
| 129 | assert.isTrue(ttlError.message.includes("Invalid duration")); | |
| 130 | assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes")); | |
| 131 | }), | |
| 132 | ); | |
| 37 | 133 | }); |
apps/server/src/cli.ts+353-23| ... | ... | @@ -1,6 +1,20 @@ |
| 1 | 1 | import { NetService } from "@t3tools/shared/Net"; |
| 2 | 2 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 3 | import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect"; | |
| 3 | import { AuthSessionId } from "@t3tools/contracts"; | |
| 4 | import { | |
| 5 | Config, | |
| 6 | Console, | |
| 7 | Duration, | |
| 8 | Effect, | |
| 9 | FileSystem, | |
| 10 | LogLevel, | |
| 11 | Option, | |
| 12 | Path, | |
| 13 | References, | |
| 14 | Schema, | |
| 15 | SchemaIssue, | |
| 16 | SchemaTransformation, | |
| 17 | } from "effect"; | |
| 4 | 18 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; |
| 5 | 19 | |
| 6 | 20 | import { |
| ... | ... | @@ -15,6 +29,14 @@ import { |
| 15 | 29 | import { readBootstrapEnvelope } from "./bootstrap"; |
| 16 | 30 | import { expandHomePath, resolveBaseDir } from "./os-jank"; |
| 17 | 31 | import { runServer } from "./server"; |
| 32 | import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts"; | |
| 33 | import { | |
| 34 | formatIssuedPairingCredential, | |
| 35 | formatIssuedSession, | |
| 36 | formatPairingCredentialList, | |
| 37 | formatSessionList, | |
| 38 | } from "./cliAuthFormat"; | |
| 39 | import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; | |
| 18 | 40 | |
| 19 | 41 | const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); |
| 20 | 42 | |
| ... | ... | @@ -25,7 +47,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ |
| 25 | 47 | t3Home: Schema.optional(Schema.String), |
| 26 | 48 | devUrl: Schema.optional(Schema.URLFromString), |
| 27 | 49 | noBrowser: Schema.optional(Schema.Boolean), |
| 28 | authToken: Schema.optional(Schema.String), | |
| 50 | desktopBootstrapToken: Schema.optional(Schema.String), | |
| 29 | 51 | autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), |
| 30 | 52 | logWebSocketEvents: Schema.optional(Schema.Boolean), |
| 31 | 53 | otlpTracesUrl: Schema.optional(Schema.String), |
| ... | ... | @@ -58,11 +80,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe( |
| 58 | 80 | Flag.withDescription("Disable automatic browser opening."), |
| 59 | 81 | Flag.optional, |
| 60 | 82 | ); |
| 61 | const authTokenFlag = Flag.string("auth-token").pipe( | |
| 62 | Flag.withDescription("Auth token required for WebSocket connections."), | |
| 63 | Flag.withAlias("token"), | |
| 64 | Flag.optional, | |
| 65 | ); | |
| 66 | 83 | const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( |
| 67 | 84 | Flag.withSchema(Schema.Int), |
| 68 | 85 | Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), |
| ... | ... | @@ -117,10 +134,6 @@ const EnvServerConfig = Config.all({ |
| 117 | 134 | Config.option, |
| 118 | 135 | Config.map(Option.getOrUndefined), |
| 119 | 136 | ), |
| 120 | authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( | |
| 121 | Config.option, | |
| 122 | Config.map(Option.getOrUndefined), | |
| 123 | ), | |
| 124 | 137 | bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( |
| 125 | 138 | Config.option, |
| 126 | 139 | Config.map(Option.getOrUndefined), |
| ... | ... | @@ -143,12 +156,16 @@ interface CliServerFlags { |
| 143 | 156 | readonly cwd: Option.Option<string>; |
| 144 | 157 | readonly devUrl: Option.Option<URL>; |
| 145 | 158 | readonly noBrowser: Option.Option<boolean>; |
| 146 | readonly authToken: Option.Option<string>; | |
| 147 | 159 | readonly bootstrapFd: Option.Option<number>; |
| 148 | 160 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; |
| 149 | 161 | readonly logWebSocketEvents: Option.Option<boolean>; |
| 150 | 162 | } |
| 151 | 163 | |
| 164 | interface CliAuthLocationFlags { | |
| 165 | readonly baseDir: Option.Option<string>; | |
| 166 | readonly devUrl: Option.Option<URL>; | |
| 167 | } | |
| 168 | ||
| 152 | 169 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => |
| 153 | 170 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); |
| 154 | 171 | |
| ... | ... | @@ -248,13 +265,9 @@ export const resolveServerConfig = ( |
| 248 | 265 | () => mode === "desktop", |
| 249 | 266 | ), |
| 250 | 267 | ); |
| 251 | const authToken = Option.getOrUndefined( | |
| 252 | resolveOptionPrecedence( | |
| 253 | flags.authToken, | |
| 254 | Option.fromUndefinedOr(env.authToken), | |
| 255 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 256 | Option.fromUndefinedOr(bootstrap.authToken), | |
| 257 | ), | |
| 268 | const desktopBootstrapToken = Option.getOrUndefined( | |
| 269 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 270 | Option.fromUndefinedOr(bootstrap.desktopBootstrapToken), | |
| 258 | 271 | ), |
| 259 | 272 | ); |
| 260 | 273 | const autoBootstrapProjectFromCwd = resolveBooleanFlag( |
| ... | ... | @@ -327,7 +340,7 @@ export const resolveServerConfig = ( |
| 327 | 340 | staticDir, |
| 328 | 341 | devUrl, |
| 329 | 342 | noBrowser, |
| 330 | authToken, | |
| 343 | desktopBootstrapToken, | |
| 331 | 344 | autoBootstrapProjectFromCwd, |
| 332 | 345 | logWebSocketEvents, |
| 333 | 346 | }; |
| ... | ... | @@ -335,6 +348,110 @@ export const resolveServerConfig = ( |
| 335 | 348 | return config; |
| 336 | 349 | }); |
| 337 | 350 | |
| 351 | const resolveCliAuthConfig = ( | |
| 352 | flags: CliAuthLocationFlags, | |
| 353 | cliLogLevel: Option.Option<LogLevel.LogLevel>, | |
| 354 | ) => | |
| 355 | resolveServerConfig( | |
| 356 | { | |
| 357 | mode: Option.none(), | |
| 358 | port: Option.none(), | |
| 359 | host: Option.none(), | |
| 360 | baseDir: flags.baseDir, | |
| 361 | cwd: Option.none(), | |
| 362 | devUrl: flags.devUrl, | |
| 363 | noBrowser: Option.none(), | |
| 364 | bootstrapFd: Option.none(), | |
| 365 | autoBootstrapProjectFromCwd: Option.none(), | |
| 366 | logWebSocketEvents: Option.none(), | |
| 367 | }, | |
| 368 | cliLogLevel, | |
| 369 | ); | |
| 370 | ||
| 371 | const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i; | |
| 372 | ||
| 373 | const parseDurationInput = (value: string): Duration.Duration | null => { | |
| 374 | const trimmed = value.trim(); | |
| 375 | if (trimmed.length === 0) return null; | |
| 376 | ||
| 377 | const shorthand = DurationShorthandPattern.exec(trimmed); | |
| 378 | const normalizedInput = shorthand?.groups | |
| 379 | ? (() => { | |
| 380 | const amountText = shorthand.groups.value; | |
| 381 | const unitText = shorthand.groups.unit; | |
| 382 | if (typeof amountText !== "string" || typeof unitText !== "string") { | |
| 383 | return null; | |
| 384 | } | |
| 385 | ||
| 386 | const amount = Number.parseInt(amountText, 10); | |
| 387 | if (!Number.isFinite(amount)) return null; | |
| 388 | ||
| 389 | switch (unitText.toLowerCase()) { | |
| 390 | case "ms": | |
| 391 | return `${amount} millis`; | |
| 392 | case "s": | |
| 393 | return `${amount} seconds`; | |
| 394 | case "m": | |
| 395 | return `${amount} minutes`; | |
| 396 | case "h": | |
| 397 | return `${amount} hours`; | |
| 398 | case "d": | |
| 399 | return `${amount} days`; | |
| 400 | case "w": | |
| 401 | return `${amount} weeks`; | |
| 402 | default: | |
| 403 | return null; | |
| 404 | } | |
| 405 | })() | |
| 406 | : (trimmed as Duration.Input); | |
| 407 | ||
| 408 | if (normalizedInput === null) return null; | |
| 409 | ||
| 410 | const decoded = Duration.fromInput(normalizedInput as Duration.Input); | |
| 411 | return Option.isSome(decoded) ? decoded.value : null; | |
| 412 | }; | |
| 413 | ||
| 414 | const DurationFromString = Schema.String.pipe( | |
| 415 | Schema.decodeTo( | |
| 416 | Schema.Duration, | |
| 417 | SchemaTransformation.transformOrFail({ | |
| 418 | decode: (value) => { | |
| 419 | const duration = parseDurationInput(value); | |
| 420 | if (duration !== null) { | |
| 421 | return Effect.succeed(duration); | |
| 422 | } | |
| 423 | return Effect.fail( | |
| 424 | new SchemaIssue.InvalidValue(Option.some(value), { | |
| 425 | message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.", | |
| 426 | }), | |
| 427 | ); | |
| 428 | }, | |
| 429 | encode: (duration) => Effect.succeed(Duration.format(duration)), | |
| 430 | }), | |
| 431 | ), | |
| 432 | ); | |
| 433 | ||
| 434 | const runWithAuthControlPlane = <A, E>( | |
| 435 | flags: CliAuthLocationFlags, | |
| 436 | run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>, | |
| 437 | options?: { | |
| 438 | readonly quietLogs?: boolean; | |
| 439 | }, | |
| 440 | ) => | |
| 441 | Effect.gen(function* () { | |
| 442 | const logLevel = yield* GlobalFlag.LogLevel; | |
| 443 | const config = yield* resolveCliAuthConfig(flags, logLevel); | |
| 444 | const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; | |
| 445 | return yield* Effect.gen(function* () { | |
| 446 | const authControlPlane = yield* AuthControlPlane; | |
| 447 | return yield* run(authControlPlane); | |
| 448 | }).pipe( | |
| 449 | Effect.provide(AuthControlPlaneRuntimeLive), | |
| 450 | Effect.provideService(ServerConfig, config), | |
| 451 | Effect.provideService(References.MinimumLogLevel, minimumLogLevel), | |
| 452 | ); | |
| 453 | }); | |
| 454 | ||
| 338 | 455 | const commandFlags = { |
| 339 | 456 | mode: modeFlag, |
| 340 | 457 | port: portFlag, |
| ... | ... | @@ -348,13 +465,216 @@ const commandFlags = { |
| 348 | 465 | ), |
| 349 | 466 | devUrl: devUrlFlag, |
| 350 | 467 | noBrowser: noBrowserFlag, |
| 351 | authToken: authTokenFlag, | |
| 352 | 468 | bootstrapFd: bootstrapFdFlag, |
| 353 | 469 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, |
| 354 | 470 | logWebSocketEvents: logWebSocketEventsFlag, |
| 355 | 471 | } as const; |
| 356 | 472 | |
| 357 | const rootCommand = Command.make("t3", commandFlags).pipe( | |
| 473 | const authLocationFlags = { | |
| 474 | baseDir: baseDirFlag, | |
| 475 | devUrl: devUrlFlag, | |
| 476 | } as const; | |
| 477 | ||
| 478 | const ttlFlag = Flag.string("ttl").pipe( | |
| 479 | Flag.withSchema(DurationFromString), | |
| 480 | Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), | |
| 481 | Flag.optional, | |
| 482 | ); | |
| 483 | ||
| 484 | const jsonFlag = Flag.boolean("json").pipe( | |
| 485 | Flag.withDescription("Emit JSON instead of human-readable output."), | |
| 486 | Flag.withDefault(false), | |
| 487 | ); | |
| 488 | ||
| 489 | const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( | |
| 490 | Flag.withDescription("Role for the issued bearer session."), | |
| 491 | Flag.withDefault("owner"), | |
| 492 | ); | |
| 493 | ||
| 494 | const labelFlag = Flag.string("label").pipe( | |
| 495 | Flag.withDescription("Optional human-readable label."), | |
| 496 | Flag.optional, | |
| 497 | ); | |
| 498 | ||
| 499 | const subjectFlag = Flag.string("subject").pipe( | |
| 500 | Flag.withDescription("Optional session subject."), | |
| 501 | Flag.optional, | |
| 502 | ); | |
| 503 | ||
| 504 | const baseUrlFlag = Flag.string("base-url").pipe( | |
| 505 | Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), | |
| 506 | Flag.optional, | |
| 507 | ); | |
| 508 | ||
| 509 | const tokenOnlyFlag = Flag.boolean("token-only").pipe( | |
| 510 | Flag.withDescription("Print only the issued bearer token."), | |
| 511 | Flag.withDefault(false), | |
| 512 | ); | |
| 513 | ||
| 514 | const pairingCreateCommand = Command.make("create", { | |
| 515 | ...authLocationFlags, | |
| 516 | ttl: ttlFlag, | |
| 517 | label: labelFlag, | |
| 518 | baseUrl: baseUrlFlag, | |
| 519 | json: jsonFlag, | |
| 520 | }).pipe( | |
| 521 | Command.withDescription("Issue a new client pairing token."), | |
| 522 | Command.withHandler((flags) => | |
| 523 | runWithAuthControlPlane( | |
| 524 | flags, | |
| 525 | (authControlPlane) => | |
| 526 | Effect.gen(function* () { | |
| 527 | const issued = yield* authControlPlane.createPairingLink({ | |
| 528 | role: "client", | |
| 529 | subject: "one-time-token", | |
| 530 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | |
| 531 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | |
| 532 | }); | |
| 533 | const output = formatIssuedPairingCredential(issued, { | |
| 534 | json: flags.json, | |
| 535 | ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), | |
| 536 | }); | |
| 537 | yield* Console.log(output); | |
| 538 | }), | |
| 539 | { | |
| 540 | quietLogs: flags.json, | |
| 541 | }, | |
| 542 | ), | |
| 543 | ), | |
| 544 | ); | |
| 545 | ||
| 546 | const pairingListCommand = Command.make("list", { | |
| 547 | ...authLocationFlags, | |
| 548 | json: jsonFlag, | |
| 549 | }).pipe( | |
| 550 | Command.withDescription("List active client pairing tokens without revealing their secrets."), | |
| 551 | Command.withHandler((flags) => | |
| 552 | runWithAuthControlPlane( | |
| 553 | flags, | |
| 554 | (authControlPlane) => | |
| 555 | Effect.gen(function* () { | |
| 556 | const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); | |
| 557 | yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); | |
| 558 | }), | |
| 559 | { | |
| 560 | quietLogs: flags.json, | |
| 561 | }, | |
| 562 | ), | |
| 563 | ), | |
| 564 | ); | |
| 565 | ||
| 566 | const pairingRevokeCommand = Command.make("revoke", { | |
| 567 | ...authLocationFlags, | |
| 568 | id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")), | |
| 569 | }).pipe( | |
| 570 | Command.withDescription("Revoke an active client pairing token."), | |
| 571 | Command.withHandler((flags) => | |
| 572 | runWithAuthControlPlane(flags, (authControlPlane) => | |
| 573 | Effect.gen(function* () { | |
| 574 | const revoked = yield* authControlPlane.revokePairingLink(flags.id); | |
| 575 | yield* Console.log( | |
| 576 | revoked | |
| 577 | ? `Revoked pairing credential ${flags.id}.\n` | |
| 578 | : `No active pairing credential found for ${flags.id}.\n`, | |
| 579 | ); | |
| 580 | }), | |
| 581 | ), | |
| 582 | ), | |
| 583 | ); | |
| 584 | ||
| 585 | const pairingCommand = Command.make("pairing").pipe( | |
| 586 | Command.withDescription("Manage one-time client pairing tokens."), | |
| 587 | Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), | |
| 588 | ); | |
| 589 | ||
| 590 | const sessionIssueCommand = Command.make("issue", { | |
| 591 | ...authLocationFlags, | |
| 592 | ttl: ttlFlag, | |
| 593 | role: sessionRoleFlag, | |
| 594 | label: labelFlag, | |
| 595 | subject: subjectFlag, | |
| 596 | tokenOnly: tokenOnlyFlag, | |
| 597 | json: jsonFlag, | |
| 598 | }).pipe( | |
| 599 | Command.withDescription("Issue a bearer session token for headless or remote clients."), | |
| 600 | Command.withHandler((flags) => | |
| 601 | runWithAuthControlPlane( | |
| 602 | flags, | |
| 603 | (authControlPlane) => | |
| 604 | Effect.gen(function* () { | |
| 605 | const issued = yield* authControlPlane.issueSession({ | |
| 606 | role: flags.role, | |
| 607 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | |
| 608 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | |
| 609 | ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), | |
| 610 | }); | |
| 611 | yield* Console.log( | |
| 612 | formatIssuedSession(issued, { | |
| 613 | json: flags.json, | |
| 614 | tokenOnly: flags.tokenOnly, | |
| 615 | }), | |
| 616 | ); | |
| 617 | }), | |
| 618 | { | |
| 619 | quietLogs: flags.json || flags.tokenOnly, | |
| 620 | }, | |
| 621 | ), | |
| 622 | ), | |
| 623 | ); | |
| 624 | ||
| 625 | const sessionListCommand = Command.make("list", { | |
| 626 | ...authLocationFlags, | |
| 627 | json: jsonFlag, | |
| 628 | }).pipe( | |
| 629 | Command.withDescription("List active sessions without revealing bearer tokens."), | |
| 630 | Command.withHandler((flags) => | |
| 631 | runWithAuthControlPlane( | |
| 632 | flags, | |
| 633 | (authControlPlane) => | |
| 634 | Effect.gen(function* () { | |
| 635 | const sessions = yield* authControlPlane.listSessions(); | |
| 636 | yield* Console.log(formatSessionList(sessions, { json: flags.json })); | |
| 637 | }), | |
| 638 | { | |
| 639 | quietLogs: flags.json, | |
| 640 | }, | |
| 641 | ), | |
| 642 | ), | |
| 643 | ); | |
| 644 | ||
| 645 | const sessionRevokeCommand = Command.make("revoke", { | |
| 646 | ...authLocationFlags, | |
| 647 | sessionId: Argument.string("session-id").pipe( | |
| 648 | Argument.withDescription("Session id to revoke."), | |
| 649 | Argument.withSchema(AuthSessionId), | |
| 650 | ), | |
| 651 | }).pipe( | |
| 652 | Command.withDescription("Revoke an active session."), | |
| 653 | Command.withHandler((flags) => | |
| 654 | runWithAuthControlPlane(flags, (authControlPlane) => | |
| 655 | Effect.gen(function* () { | |
| 656 | const revoked = yield* authControlPlane.revokeSession(flags.sessionId); | |
| 657 | yield* Console.log( | |
| 658 | revoked | |
| 659 | ? `Revoked session ${flags.sessionId}.\n` | |
| 660 | : `No active session found for ${flags.sessionId}.\n`, | |
| 661 | ); | |
| 662 | }), | |
| 663 | ), | |
| 664 | ), | |
| 665 | ); | |
| 666 | ||
| 667 | const sessionCommand = Command.make("session").pipe( | |
| 668 | Command.withDescription("Manage bearer sessions."), | |
| 669 | Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), | |
| 670 | ); | |
| 671 | ||
| 672 | const authCommand = Command.make("auth").pipe( | |
| 673 | Command.withDescription("Manage the local auth control plane for headless deployments."), | |
| 674 | Command.withSubcommands([pairingCommand, sessionCommand]), | |
| 675 | ); | |
| 676 | ||
| 677 | const startCommand = Command.make("start", commandFlags).pipe( | |
| 358 | 678 | Command.withDescription("Run the T3 Code server."), |
| 359 | 679 | Command.withHandler((flags) => |
| 360 | 680 | Effect.gen(function* () { |
| ... | ... | @@ -365,4 +685,14 @@ const rootCommand = Command.make("t3", commandFlags).pipe( |
| 365 | 685 | ), |
| 366 | 686 | ); |
| 367 | 687 | |
| 368 | export const cli = rootCommand; | |
| 688 | export const cli = Command.make("t3", commandFlags).pipe( | |
| 689 | Command.withDescription("Run the T3 Code server."), | |
| 690 | Command.withHandler((flags) => | |
| 691 | Effect.gen(function* () { | |
| 692 | const logLevel = yield* GlobalFlag.LogLevel; | |
| 693 | const config = yield* resolveServerConfig(flags, logLevel); | |
| 694 | return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); | |
| 695 | }), | |
| 696 | ), | |
| 697 | Command.withSubcommands([startCommand, authCommand]), | |
| 698 | ); |
apps/server/src/cliAuthFormat.test.ts created+88| ... | ... | @@ -0,0 +1,88 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { DateTime } from "effect"; | |
| 3 | ||
| 4 | import { | |
| 5 | formatIssuedPairingCredential, | |
| 6 | formatIssuedSession, | |
| 7 | formatPairingCredentialList, | |
| 8 | formatSessionList, | |
| 9 | } from "./cliAuthFormat.ts"; | |
| 10 | ||
| 11 | it("formats issued pairing credentials with the secret and optional pair URL", () => { | |
| 12 | const output = formatIssuedPairingCredential( | |
| 13 | { | |
| 14 | id: "pairing-1", | |
| 15 | credential: "secret-pairing-token", | |
| 16 | role: "client", | |
| 17 | subject: "one-time-token", | |
| 18 | createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), | |
| 19 | expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), | |
| 20 | }, | |
| 21 | { baseUrl: "https://example.com", json: false }, | |
| 22 | ); | |
| 23 | ||
| 24 | expect(output).toContain("secret-pairing-token"); | |
| 25 | expect(output).toContain("https://example.com/pair#token=secret-pairing-token"); | |
| 26 | }); | |
| 27 | ||
| 28 | it("formats pairing listings without exposing the secret token", () => { | |
| 29 | const output = formatPairingCredentialList( | |
| 30 | [ | |
| 31 | { | |
| 32 | id: "pairing-1", | |
| 33 | credential: "secret-pairing-token", | |
| 34 | subject: "one-time-token", | |
| 35 | label: "Phone", | |
| 36 | role: "client", | |
| 37 | createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), | |
| 38 | expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), | |
| 39 | }, | |
| 40 | ], | |
| 41 | { json: false }, | |
| 42 | ); | |
| 43 | ||
| 44 | expect(output).toContain("pairing-1"); | |
| 45 | expect(output).not.toContain("secret-pairing-token"); | |
| 46 | }); | |
| 47 | ||
| 48 | it("formats issued sessions with the bearer token but omits tokens from listings", () => { | |
| 49 | const issuedOutput = formatIssuedSession( | |
| 50 | { | |
| 51 | sessionId: "session-1" as never, | |
| 52 | token: "secret-session-token", | |
| 53 | method: "bearer-session-token", | |
| 54 | role: "owner", | |
| 55 | subject: "cli-issued-session", | |
| 56 | client: { | |
| 57 | label: "deploy-bot", | |
| 58 | deviceType: "bot", | |
| 59 | }, | |
| 60 | expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), | |
| 61 | }, | |
| 62 | { json: false }, | |
| 63 | ); | |
| 64 | ||
| 65 | const listedOutput = formatSessionList( | |
| 66 | [ | |
| 67 | { | |
| 68 | sessionId: "session-1" as never, | |
| 69 | method: "bearer-session-token", | |
| 70 | role: "owner", | |
| 71 | subject: "cli-issued-session", | |
| 72 | client: { | |
| 73 | label: "deploy-bot", | |
| 74 | deviceType: "bot", | |
| 75 | }, | |
| 76 | connected: false, | |
| 77 | current: false, | |
| 78 | issuedAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), | |
| 79 | expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), | |
| 80 | lastConnectedAt: null, | |
| 81 | }, | |
| 82 | ], | |
| 83 | { json: false }, | |
| 84 | ); | |
| 85 | ||
| 86 | expect(issuedOutput).toContain("secret-session-token"); | |
| 87 | expect(listedOutput).not.toContain("secret-session-token"); | |
| 88 | }); |
apps/server/src/cliAuthFormat.ts created+190| ... | ... | @@ -0,0 +1,190 @@ |
| 1 | import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; | |
| 2 | import { DateTime } from "effect"; | |
| 3 | ||
| 4 | import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts"; | |
| 5 | ||
| 6 | const newline = "\n"; | |
| 7 | ||
| 8 | function serializeOptionalFields(values: ReadonlyArray<string | null | undefined>) { | |
| 9 | return values.filter((value): value is string => typeof value === "string" && value.length > 0); | |
| 10 | } | |
| 11 | ||
| 12 | function formatClientMetadata(metadata: AuthClientMetadata): string { | |
| 13 | const details = serializeOptionalFields([ | |
| 14 | metadata.label, | |
| 15 | metadata.deviceType !== "unknown" ? metadata.deviceType : undefined, | |
| 16 | metadata.os, | |
| 17 | metadata.browser, | |
| 18 | metadata.ipAddress, | |
| 19 | ]); | |
| 20 | return details.length > 0 ? details.join(" | ") : "unlabeled client"; | |
| 21 | } | |
| 22 | ||
| 23 | function toIsoString(value: DateTime.DateTime | DateTime.Utc): string { | |
| 24 | return DateTime.formatIso(DateTime.toUtc(value)); | |
| 25 | } | |
| 26 | ||
| 27 | export function formatIssuedPairingCredential( | |
| 28 | credential: IssuedPairingLink, | |
| 29 | options?: { | |
| 30 | readonly json?: boolean; | |
| 31 | readonly baseUrl?: string; | |
| 32 | }, | |
| 33 | ): string { | |
| 34 | const pairUrl = | |
| 35 | options?.baseUrl != null && options.baseUrl.length > 0 | |
| 36 | ? (() => { | |
| 37 | const url = new URL("/pair", options.baseUrl); | |
| 38 | url.searchParams.delete("token"); | |
| 39 | url.hash = new URLSearchParams([["token", credential.credential]]).toString(); | |
| 40 | return url.toString(); | |
| 41 | })() | |
| 42 | : undefined; | |
| 43 | ||
| 44 | if (options?.json) { | |
| 45 | return `${JSON.stringify( | |
| 46 | { | |
| 47 | id: credential.id, | |
| 48 | credential: credential.credential, | |
| 49 | ...(credential.label ? { label: credential.label } : {}), | |
| 50 | role: credential.role, | |
| 51 | expiresAt: toIsoString(credential.expiresAt), | |
| 52 | ...(pairUrl ? { pairUrl } : {}), | |
| 53 | }, | |
| 54 | null, | |
| 55 | 2, | |
| 56 | )}${newline}`; | |
| 57 | } | |
| 58 | ||
| 59 | return ( | |
| 60 | [ | |
| 61 | `Issued client pairing token ${credential.id}.`, | |
| 62 | `Token: ${credential.credential}`, | |
| 63 | ...(pairUrl ? [`Pair URL: ${pairUrl}`] : []), | |
| 64 | `Expires at: ${credential.expiresAt}`, | |
| 65 | ].join(newline) + newline | |
| 66 | ); | |
| 67 | } | |
| 68 | ||
| 69 | export function formatPairingCredentialList( | |
| 70 | credentials: ReadonlyArray<AuthPairingLink>, | |
| 71 | options?: { | |
| 72 | readonly json?: boolean; | |
| 73 | }, | |
| 74 | ): string { | |
| 75 | if (options?.json) { | |
| 76 | return `${JSON.stringify( | |
| 77 | credentials.map((credential) => ({ | |
| 78 | id: credential.id, | |
| 79 | ...(credential.label ? { label: credential.label } : {}), | |
| 80 | role: credential.role, | |
| 81 | createdAt: toIsoString(credential.createdAt), | |
| 82 | expiresAt: toIsoString(credential.expiresAt), | |
| 83 | })), | |
| 84 | null, | |
| 85 | 2, | |
| 86 | )}${newline}`; | |
| 87 | } | |
| 88 | ||
| 89 | if (credentials.length === 0) { | |
| 90 | return `No active pairing credentials.${newline}`; | |
| 91 | } | |
| 92 | ||
| 93 | return ( | |
| 94 | credentials | |
| 95 | .map((credential) => | |
| 96 | [ | |
| 97 | `${credential.id}${credential.label ? ` (${credential.label})` : ""}`, | |
| 98 | ` role: ${credential.role}`, | |
| 99 | ` created: ${toIsoString(credential.createdAt)}`, | |
| 100 | ` expires: ${toIsoString(credential.expiresAt)}`, | |
| 101 | ].join(newline), | |
| 102 | ) | |
| 103 | .join(`${newline}${newline}`) + newline | |
| 104 | ); | |
| 105 | } | |
| 106 | ||
| 107 | export function formatIssuedSession( | |
| 108 | session: IssuedBearerSession, | |
| 109 | options?: { | |
| 110 | readonly json?: boolean; | |
| 111 | readonly tokenOnly?: boolean; | |
| 112 | }, | |
| 113 | ): string { | |
| 114 | if (options?.tokenOnly) { | |
| 115 | return `${session.token}${newline}`; | |
| 116 | } | |
| 117 | ||
| 118 | if (options?.json) { | |
| 119 | return `${JSON.stringify( | |
| 120 | { | |
| 121 | sessionId: session.sessionId, | |
| 122 | token: session.token, | |
| 123 | method: session.method, | |
| 124 | role: session.role, | |
| 125 | subject: session.subject, | |
| 126 | client: session.client, | |
| 127 | expiresAt: toIsoString(session.expiresAt), | |
| 128 | }, | |
| 129 | null, | |
| 130 | 2, | |
| 131 | )}${newline}`; | |
| 132 | } | |
| 133 | ||
| 134 | return ( | |
| 135 | [ | |
| 136 | `Issued ${session.role} bearer session ${session.sessionId}.`, | |
| 137 | `Token: ${session.token}`, | |
| 138 | `Subject: ${session.subject}`, | |
| 139 | `Client: ${formatClientMetadata(session.client)}`, | |
| 140 | `Expires at: ${toIsoString(session.expiresAt)}`, | |
| 141 | ].join(newline) + newline | |
| 142 | ); | |
| 143 | } | |
| 144 | ||
| 145 | export function formatSessionList( | |
| 146 | sessions: ReadonlyArray<AuthClientSession>, | |
| 147 | options?: { | |
| 148 | readonly json?: boolean; | |
| 149 | }, | |
| 150 | ): string { | |
| 151 | if (options?.json) { | |
| 152 | return `${JSON.stringify( | |
| 153 | sessions.map((session) => ({ | |
| 154 | sessionId: session.sessionId, | |
| 155 | method: session.method, | |
| 156 | role: session.role, | |
| 157 | subject: session.subject, | |
| 158 | client: session.client, | |
| 159 | connected: session.connected, | |
| 160 | issuedAt: toIsoString(session.issuedAt), | |
| 161 | expiresAt: toIsoString(session.expiresAt), | |
| 162 | lastConnectedAt: session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : null, | |
| 163 | })), | |
| 164 | null, | |
| 165 | 2, | |
| 166 | )}${newline}`; | |
| 167 | } | |
| 168 | ||
| 169 | if (sessions.length === 0) { | |
| 170 | return `No active sessions.${newline}`; | |
| 171 | } | |
| 172 | ||
| 173 | return ( | |
| 174 | sessions | |
| 175 | .map((session) => | |
| 176 | [ | |
| 177 | `${session.sessionId} [${session.role}]${session.connected ? " connected" : ""}`, | |
| 178 | ` method: ${session.method}`, | |
| 179 | ` subject: ${session.subject}`, | |
| 180 | ` client: ${formatClientMetadata(session.client)}`, | |
| 181 | ` issued: ${toIsoString(session.issuedAt)}`, | |
| 182 | ` last connected: ${ | |
| 183 | session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : "never" | |
| 184 | }`, | |
| 185 | ` expires: ${toIsoString(session.expiresAt)}`, | |
| 186 | ].join(newline), | |
| 187 | ) | |
| 188 | .join(`${newline}${newline}`) + newline | |
| 189 | ); | |
| 190 | } |
apps/server/src/config.ts+4-2| ... | ... | @@ -31,6 +31,7 @@ export interface ServerDerivedPaths { |
| 31 | 31 | readonly terminalLogsDir: string; |
| 32 | 32 | readonly anonymousIdPath: string; |
| 33 | 33 | readonly environmentIdPath: string; |
| 34 | readonly secretsDir: string; | |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | 37 | /** |
| ... | ... | @@ -55,7 +56,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { |
| 55 | 56 | readonly staticDir: string | undefined; |
| 56 | 57 | readonly devUrl: URL | undefined; |
| 57 | 58 | readonly noBrowser: boolean; |
| 58 | readonly authToken: string | undefined; | |
| 59 | readonly desktopBootstrapToken: string | undefined; | |
| 59 | 60 | readonly autoBootstrapProjectFromCwd: boolean; |
| 60 | 61 | readonly logWebSocketEvents: boolean; |
| 61 | 62 | } |
| ... | ... | @@ -85,6 +86,7 @@ export const deriveServerPaths = Effect.fn(function* ( |
| 85 | 86 | terminalLogsDir: join(logsDir, "terminals"), |
| 86 | 87 | anonymousIdPath: join(stateDir, "anonymous-id"), |
| 87 | 88 | environmentIdPath: join(stateDir, "environment-id"), |
| 89 | secretsDir: join(stateDir, "secrets"), | |
| 88 | 90 | }; |
| 89 | 91 | }); |
| 90 | 92 | |
| ... | ... | @@ -147,7 +149,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS |
| 147 | 149 | logWebSocketEvents: false, |
| 148 | 150 | port: 0, |
| 149 | 151 | host: undefined, |
| 150 | authToken: undefined, | |
| 152 | desktopBootstrapToken: undefined, | |
| 151 | 153 | staticDir: undefined, |
| 152 | 154 | devUrl, |
| 153 | 155 | noBrowser: false, |
apps/server/src/environment/Layers/ServerEnvironment.test.ts+1-1| ... | ... | @@ -32,7 +32,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { |
| 32 | 32 | logWebSocketEvents: false, |
| 33 | 33 | port: 0, |
| 34 | 34 | host: undefined, |
| 35 | authToken: undefined, | |
| 35 | desktopBootstrapToken: undefined, | |
| 36 | 36 | staticDir: undefined, |
| 37 | 37 | devUrl: undefined, |
| 38 | 38 | noBrowser: false, |
apps/server/src/environment/Layers/ServerEnvironment.ts+4-6| ... | ... | @@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path, Random } from "effect"; |
| 4 | 4 | import { ServerConfig } from "../../config.ts"; |
| 5 | 5 | import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; |
| 6 | 6 | import { version } from "../../../package.json" with { type: "json" }; |
| 7 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | |
| 7 | 8 | |
| 8 | 9 | function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { |
| 9 | 10 | switch (process.platform) { |
| ... | ... | @@ -65,12 +66,9 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function |
| 65 | 66 | |
| 66 | 67 | const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw); |
| 67 | 68 | const cwdBaseName = path.basename(serverConfig.cwd).trim(); |
| 68 | const label = | |
| 69 | serverConfig.mode === "desktop" | |
| 70 | ? "Local environment" | |
| 71 | : cwdBaseName.length > 0 | |
| 72 | ? cwdBaseName | |
| 73 | : "T3 environment"; | |
| 69 | const label = yield* resolveServerEnvironmentLabel({ | |
| 70 | cwdBaseName, | |
| 71 | }); | |
| 74 | 72 | |
| 75 | 73 | const descriptor: ExecutionEnvironmentDescriptor = { |
| 76 | 74 | environmentId, |
apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts created+150| ... | ... | @@ -0,0 +1,150 @@ |
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | |
| 2 | import { Effect, FileSystem } from "effect"; | |
| 3 | import { vi } from "vitest"; | |
| 4 | ||
| 5 | vi.mock("../../processRunner.ts", () => ({ | |
| 6 | runProcess: vi.fn(), | |
| 7 | })); | |
| 8 | ||
| 9 | import { runProcess } from "../../processRunner.ts"; | |
| 10 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | |
| 11 | ||
| 12 | const mockedRunProcess = vi.mocked(runProcess); | |
| 13 | const NoopFileSystemLayer = FileSystem.layerNoop({}); | |
| 14 | ||
| 15 | afterEach(() => { | |
| 16 | mockedRunProcess.mockReset(); | |
| 17 | }); | |
| 18 | ||
| 19 | describe("resolveServerEnvironmentLabel", () => { | |
| 20 | it.effect("uses hostname fallback regardless of launch mode", () => | |
| 21 | Effect.gen(function* () { | |
| 22 | const result = yield* resolveServerEnvironmentLabel({ | |
| 23 | cwdBaseName: "t3code", | |
| 24 | platform: "win32", | |
| 25 | hostname: "macbook-pro", | |
| 26 | }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 27 | ||
| 28 | expect(result).toBe("macbook-pro"); | |
| 29 | }), | |
| 30 | ); | |
| 31 | ||
| 32 | it.effect("prefers the macOS ComputerName", () => | |
| 33 | Effect.gen(function* () { | |
| 34 | mockedRunProcess.mockResolvedValueOnce({ | |
| 35 | stdout: " Julius's MacBook Pro \n", | |
| 36 | stderr: "", | |
| 37 | code: 0, | |
| 38 | signal: null, | |
| 39 | timedOut: false, | |
| 40 | }); | |
| 41 | ||
| 42 | const result = yield* resolveServerEnvironmentLabel({ | |
| 43 | cwdBaseName: "t3code", | |
| 44 | platform: "darwin", | |
| 45 | hostname: "macbook-pro", | |
| 46 | }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 47 | ||
| 48 | expect(result).toBe("Julius's MacBook Pro"); | |
| 49 | expect(mockedRunProcess).toHaveBeenCalledWith( | |
| 50 | "scutil", | |
| 51 | ["--get", "ComputerName"], | |
| 52 | expect.objectContaining({ allowNonZeroExit: true }), | |
| 53 | ); | |
| 54 | }), | |
| 55 | ); | |
| 56 | ||
| 57 | it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => | |
| 58 | Effect.gen(function* () { | |
| 59 | const result = yield* resolveServerEnvironmentLabel({ | |
| 60 | cwdBaseName: "t3code", | |
| 61 | platform: "linux", | |
| 62 | hostname: "buildbox", | |
| 63 | }).pipe( | |
| 64 | Effect.provide( | |
| 65 | FileSystem.layerNoop({ | |
| 66 | exists: (path) => Effect.succeed(path === "/etc/machine-info"), | |
| 67 | readFileString: (path) => | |
| 68 | path === "/etc/machine-info" | |
| 69 | ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') | |
| 70 | : Effect.succeed(""), | |
| 71 | }), | |
| 72 | ), | |
| 73 | ); | |
| 74 | ||
| 75 | expect(result).toBe("Build Agent 01"); | |
| 76 | expect(mockedRunProcess).not.toHaveBeenCalled(); | |
| 77 | }), | |
| 78 | ); | |
| 79 | ||
| 80 | it.effect("falls back to hostnamectl pretty hostname on Linux", () => | |
| 81 | Effect.gen(function* () { | |
| 82 | mockedRunProcess.mockResolvedValueOnce({ | |
| 83 | stdout: "CI Runner\n", | |
| 84 | stderr: "", | |
| 85 | code: 0, | |
| 86 | signal: null, | |
| 87 | timedOut: false, | |
| 88 | }); | |
| 89 | ||
| 90 | const result = yield* resolveServerEnvironmentLabel({ | |
| 91 | cwdBaseName: "t3code", | |
| 92 | platform: "linux", | |
| 93 | hostname: "runner-01", | |
| 94 | }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 95 | ||
| 96 | expect(result).toBe("CI Runner"); | |
| 97 | expect(mockedRunProcess).toHaveBeenCalledWith( | |
| 98 | "hostnamectl", | |
| 99 | ["--pretty"], | |
| 100 | expect.objectContaining({ allowNonZeroExit: true }), | |
| 101 | ); | |
| 102 | }), | |
| 103 | ); | |
| 104 | ||
| 105 | it.effect("falls back to the hostname when friendly labels are unavailable", () => | |
| 106 | Effect.gen(function* () { | |
| 107 | const result = yield* resolveServerEnvironmentLabel({ | |
| 108 | cwdBaseName: "t3code", | |
| 109 | platform: "win32", | |
| 110 | hostname: "JULIUS-LAPTOP", | |
| 111 | }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 112 | ||
| 113 | expect(result).toBe("JULIUS-LAPTOP"); | |
| 114 | }), | |
| 115 | ); | |
| 116 | ||
| 117 | it.effect("falls back to the hostname when the friendly-label command is missing", () => | |
| 118 | Effect.gen(function* () { | |
| 119 | mockedRunProcess.mockRejectedValueOnce(new Error("spawn scutil ENOENT")); | |
| 120 | ||
| 121 | const result = yield* resolveServerEnvironmentLabel({ | |
| 122 | cwdBaseName: "t3code", | |
| 123 | platform: "darwin", | |
| 124 | hostname: "macbook-pro", | |
| 125 | }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 126 | ||
| 127 | expect(result).toBe("macbook-pro"); | |
| 128 | }), | |
| 129 | ); | |
| 130 | ||
| 131 | it.effect("falls back to the cwd basename when the hostname is blank", () => | |
| 132 | Effect.gen(function* () { | |
| 133 | mockedRunProcess.mockResolvedValueOnce({ | |
| 134 | stdout: " ", | |
| 135 | stderr: "", | |
| 136 | code: 0, | |
| 137 | signal: null, | |
| 138 | timedOut: false, | |
| 139 | }); | |
| 140 | ||
| 141 | const result = yield* resolveServerEnvironmentLabel({ | |
| 142 | cwdBaseName: "t3code", | |
| 143 | platform: "linux", | |
| 144 | hostname: " ", | |
| 145 | }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 146 | ||
| 147 | expect(result).toBe("t3code"); | |
| 148 | }), | |
| 149 | ); | |
| 150 | }); |
apps/server/src/environment/Layers/ServerEnvironmentLabel.ts created+104| ... | ... | @@ -0,0 +1,104 @@ |
| 1 | import * as OS from "node:os"; | |
| 2 | ||
| 3 | import { Effect, FileSystem } from "effect"; | |
| 4 | ||
| 5 | import { runProcess } from "../../processRunner.ts"; | |
| 6 | ||
| 7 | interface ResolveServerEnvironmentLabelInput { | |
| 8 | readonly cwdBaseName: string; | |
| 9 | readonly platform?: NodeJS.Platform; | |
| 10 | readonly hostname?: string | null; | |
| 11 | } | |
| 12 | ||
| 13 | function normalizeLabel(value: string | null | undefined): string | null { | |
| 14 | const trimmed = value?.trim(); | |
| 15 | return trimmed && trimmed.length > 0 ? trimmed : null; | |
| 16 | } | |
| 17 | ||
| 18 | function parseMachineInfoValue(raw: string, key: string): string | null { | |
| 19 | for (const line of raw.split(/\r?\n/g)) { | |
| 20 | const trimmed = line.trim(); | |
| 21 | if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { | |
| 22 | continue; | |
| 23 | } | |
| 24 | const value = trimmed.slice(key.length + 1).trim(); | |
| 25 | if ( | |
| 26 | (value.startsWith('"') && value.endsWith('"')) || | |
| 27 | (value.startsWith("'") && value.endsWith("'")) | |
| 28 | ) { | |
| 29 | return normalizeLabel(value.slice(1, -1)); | |
| 30 | } | |
| 31 | return normalizeLabel(value); | |
| 32 | } | |
| 33 | return null; | |
| 34 | } | |
| 35 | ||
| 36 | const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { | |
| 37 | const fileSystem = yield* FileSystem.FileSystem; | |
| 38 | const exists = yield* fileSystem | |
| 39 | .exists("/etc/machine-info") | |
| 40 | .pipe(Effect.orElseSucceed(() => false)); | |
| 41 | if (!exists) { | |
| 42 | return null; | |
| 43 | } | |
| 44 | ||
| 45 | return yield* fileSystem | |
| 46 | .readFileString("/etc/machine-info") | |
| 47 | .pipe(Effect.orElseSucceed(() => null)); | |
| 48 | }); | |
| 49 | ||
| 50 | const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( | |
| 51 | command: string, | |
| 52 | args: readonly string[], | |
| 53 | ) { | |
| 54 | const result = yield* Effect.tryPromise(() => | |
| 55 | runProcess(command, args, { | |
| 56 | allowNonZeroExit: true, | |
| 57 | }), | |
| 58 | ).pipe(Effect.orElseSucceed(() => null)); | |
| 59 | ||
| 60 | if (!result || result.code !== 0) { | |
| 61 | return null; | |
| 62 | } | |
| 63 | ||
| 64 | return normalizeLabel(result.stdout); | |
| 65 | }); | |
| 66 | ||
| 67 | const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( | |
| 68 | platform: NodeJS.Platform, | |
| 69 | ) { | |
| 70 | if (platform === "darwin") { | |
| 71 | return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); | |
| 72 | } | |
| 73 | ||
| 74 | if (platform === "linux") { | |
| 75 | const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); | |
| 76 | if (machineInfo) { | |
| 77 | const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); | |
| 78 | if (prettyHostname) { | |
| 79 | return prettyHostname; | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); | |
| 84 | } | |
| 85 | ||
| 86 | return null; | |
| 87 | }); | |
| 88 | ||
| 89 | export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( | |
| 90 | input: ResolveServerEnvironmentLabelInput, | |
| 91 | ) { | |
| 92 | const platform = input.platform ?? process.platform; | |
| 93 | const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform); | |
| 94 | if (friendlyHostLabel) { | |
| 95 | return friendlyHostLabel; | |
| 96 | } | |
| 97 | ||
| 98 | const hostname = normalizeLabel(input.hostname ?? OS.hostname()); | |
| 99 | if (hostname) { | |
| 100 | return hostname; | |
| 101 | } | |
| 102 | ||
| 103 | return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; | |
| 104 | }); |
apps/server/src/http.test.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; | |
| 4 | ||
| 5 | describe("http dev routing", () => { | |
| 6 | it("treats localhost and loopback addresses as local", () => { | |
| 7 | expect(isLoopbackHostname("127.0.0.1")).toBe(true); | |
| 8 | expect(isLoopbackHostname("localhost")).toBe(true); | |
| 9 | expect(isLoopbackHostname("::1")).toBe(true); | |
| 10 | expect(isLoopbackHostname("[::1]")).toBe(true); | |
| 11 | }); | |
| 12 | ||
| 13 | it("does not treat LAN addresses as local", () => { | |
| 14 | expect(isLoopbackHostname("192.168.86.35")).toBe(false); | |
| 15 | expect(isLoopbackHostname("10.0.0.24")).toBe(false); | |
| 16 | expect(isLoopbackHostname("example.local")).toBe(false); | |
| 17 | }); | |
| 18 | ||
| 19 | it("preserves path and query when redirecting to the dev server", () => { | |
| 20 | const devUrl = new URL("http://127.0.0.1:5173/"); | |
| 21 | const requestUrl = new URL("http://127.0.0.1:3774/pair?token=test-token"); | |
| 22 | ||
| 23 | expect(resolveDevRedirectUrl(devUrl, requestUrl)).toBe( | |
| 24 | "http://127.0.0.1:5173/pair?token=test-token", | |
| 25 | ); | |
| 26 | }); | |
| 27 | }); |
apps/server/src/http.ts+58-17| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import Mime from "@effect/platform-node/Mime"; |
| 2 | import { Data, Effect, FileSystem, Layer, Option, Path } from "effect"; | |
| 2 | import { Data, Effect, FileSystem, Option, Path } from "effect"; | |
| 3 | 3 | import { cast } from "effect/Function"; |
| 4 | 4 | import { |
| 5 | 5 | HttpBody, |
| ... | ... | @@ -17,14 +17,57 @@ import { |
| 17 | 17 | resolveAttachmentRelativePath, |
| 18 | 18 | } from "./attachmentPaths"; |
| 19 | 19 | import { resolveAttachmentPathById } from "./attachmentStore"; |
| 20 | import { ServerConfig } from "./config"; | |
| 20 | import { resolveStaticDir, ServerConfig } from "./config"; | |
| 21 | 21 | import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; |
| 22 | 22 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; |
| 23 | 23 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; |
| 24 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; | |
| 25 | import { respondToAuthError } from "./auth/http.ts"; | |
| 26 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | |
| 24 | 27 | |
| 25 | 28 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; |
| 26 | 29 | const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; |
| 27 | 30 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
| 31 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | |
| 32 | ||
| 33 | export const browserApiCorsLayer = HttpRouter.cors({ | |
| 34 | allowedMethods: ["GET", "POST", "OPTIONS"], | |
| 35 | allowedHeaders: ["authorization", "b3", "traceparent", "content-type"], | |
| 36 | maxAge: 600, | |
| 37 | }); | |
| 38 | ||
| 39 | export function isLoopbackHostname(hostname: string): boolean { | |
| 40 | const normalizedHostname = hostname | |
| 41 | .trim() | |
| 42 | .toLowerCase() | |
| 43 | .replace(/^\[(.*)\]$/, "$1"); | |
| 44 | return LOOPBACK_HOSTNAMES.has(normalizedHostname); | |
| 45 | } | |
| 46 | ||
| 47 | export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { | |
| 48 | const redirectUrl = new URL(devUrl.toString()); | |
| 49 | redirectUrl.pathname = requestUrl.pathname; | |
| 50 | redirectUrl.search = requestUrl.search; | |
| 51 | redirectUrl.hash = requestUrl.hash; | |
| 52 | return redirectUrl.toString(); | |
| 53 | } | |
| 54 | ||
| 55 | const requireAuthenticatedRequest = Effect.gen(function* () { | |
| 56 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 57 | const serverAuth = yield* ServerAuth; | |
| 58 | yield* serverAuth.authenticateHttpRequest(request); | |
| 59 | }); | |
| 60 | ||
| 61 | export const serverEnvironmentRouteLayer = HttpRouter.add( | |
| 62 | "GET", | |
| 63 | "/.well-known/t3/environment", | |
| 64 | Effect.gen(function* () { | |
| 65 | const descriptor = yield* Effect.service(ServerEnvironment).pipe( | |
| 66 | Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor), | |
| 67 | ); | |
| 68 | return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 }); | |
| 69 | }), | |
| 70 | ); | |
| 28 | 71 | |
| 29 | 72 | class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ |
| 30 | 73 | readonly cause: unknown; |
| ... | ... | @@ -35,6 +78,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 35 | 78 | "POST", |
| 36 | 79 | OTLP_TRACES_PROXY_PATH, |
| 37 | 80 | Effect.gen(function* () { |
| 81 | yield* requireAuthenticatedRequest; | |
| 38 | 82 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 39 | 83 | const config = yield* ServerConfig; |
| 40 | 84 | const otlpTracesUrl = config.otlpTracesUrl; |
| ... | ... | @@ -76,21 +120,14 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 76 | 120 | Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), |
| 77 | 121 | ), |
| 78 | 122 | ); |
| 79 | }), | |
| 80 | ).pipe( | |
| 81 | Layer.provide( | |
| 82 | HttpRouter.cors({ | |
| 83 | allowedMethods: ["POST", "OPTIONS"], | |
| 84 | allowedHeaders: ["content-type"], | |
| 85 | maxAge: 600, | |
| 86 | }), | |
| 87 | ), | |
| 123 | }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 88 | 124 | ); |
| 89 | 125 | |
| 90 | 126 | export const attachmentsRouteLayer = HttpRouter.add( |
| 91 | 127 | "GET", |
| 92 | 128 | `${ATTACHMENTS_ROUTE_PREFIX}/*`, |
| 93 | 129 | Effect.gen(function* () { |
| 130 | yield* requireAuthenticatedRequest; | |
| 94 | 131 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 95 | 132 | const url = HttpServerRequest.toURL(request); |
| 96 | 133 | if (Option.isNone(url)) { |
| ... | ... | @@ -139,13 +176,14 @@ export const attachmentsRouteLayer = HttpRouter.add( |
| 139 | 176 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), |
| 140 | 177 | ), |
| 141 | 178 | ); |
| 142 | }), | |
| 179 | }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 143 | 180 | ); |
| 144 | 181 | |
| 145 | 182 | export const projectFaviconRouteLayer = HttpRouter.add( |
| 146 | 183 | "GET", |
| 147 | 184 | "/api/project-favicon", |
| 148 | 185 | Effect.gen(function* () { |
| 186 | yield* requireAuthenticatedRequest; | |
| 149 | 187 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 150 | 188 | const url = HttpServerRequest.toURL(request); |
| 151 | 189 | if (Option.isNone(url)) { |
| ... | ... | @@ -179,7 +217,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( |
| 179 | 217 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), |
| 180 | 218 | ), |
| 181 | 219 | ); |
| 182 | }), | |
| 220 | }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 183 | 221 | ); |
| 184 | 222 | |
| 185 | 223 | export const staticAndDevRouteLayer = HttpRouter.add( |
| ... | ... | @@ -193,11 +231,14 @@ export const staticAndDevRouteLayer = HttpRouter.add( |
| 193 | 231 | } |
| 194 | 232 | |
| 195 | 233 | const config = yield* ServerConfig; |
| 196 | if (config.devUrl) { | |
| 197 | return HttpServerResponse.redirect(config.devUrl.href, { status: 302 }); | |
| 234 | if (config.devUrl && isLoopbackHostname(url.value.hostname)) { | |
| 235 | return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { | |
| 236 | status: 302, | |
| 237 | }); | |
| 198 | 238 | } |
| 199 | 239 | |
| 200 | if (!config.staticDir) { | |
| 240 | const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined); | |
| 241 | if (!staticDir) { | |
| 201 | 242 | return HttpServerResponse.text("No static directory configured and no dev URL set.", { |
| 202 | 243 | status: 503, |
| 203 | 244 | }); |
| ... | ... | @@ -205,7 +246,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( |
| 205 | 246 | |
| 206 | 247 | const fileSystem = yield* FileSystem.FileSystem; |
| 207 | 248 | const path = yield* Path.Path; |
| 208 | const staticRoot = path.resolve(config.staticDir); | |
| 249 | const staticRoot = path.resolve(staticDir); | |
| 209 | 250 | const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; |
| 210 | 251 | const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); |
| 211 | 252 | const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); |
apps/server/src/keybindings.test.ts+18-1| ... | ... | @@ -2,7 +2,7 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c |
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | 4 | import { assertFailure } from "@effect/vitest/utils"; |
| 5 | import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; | |
| 5 | import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; | |
| 6 | 6 | import { ServerConfig } from "./config"; |
| 7 | 7 | |
| 8 | 8 | import { |
| ... | ... | @@ -149,6 +149,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 149 | 149 | }), |
| 150 | 150 | ); |
| 151 | 151 | |
| 152 | it.effect("formats invalid resolved keybinding rules with the custom message", () => | |
| 153 | Effect.sync(() => { | |
| 154 | const result = Schema.decodeUnknownExit(ResolvedKeybindingFromConfig)({ | |
| 155 | key: "mod+shift+d+o", | |
| 156 | command: "terminal.new", | |
| 157 | }); | |
| 158 | ||
| 159 | if (result._tag !== "Failure") { | |
| 160 | assert.fail("Expected invalid keybinding decode to fail"); | |
| 161 | } | |
| 162 | ||
| 163 | const detail = Cause.pretty(result.cause); | |
| 164 | assert.isTrue(detail.includes("Invalid keybinding rule")); | |
| 165 | assert.isFalse(detail.includes("Invalid data")); | |
| 166 | }), | |
| 167 | ); | |
| 168 | ||
| 152 | 169 | it.effect("bootstraps default keybindings when config file is missing", () => |
| 153 | 170 | Effect.gen(function* () { |
| 154 | 171 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/keybindings.ts+2-2| ... | ... | @@ -322,7 +322,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( |
| 322 | 322 | Predicate.isNotNull, |
| 323 | 323 | () => |
| 324 | 324 | new SchemaIssue.InvalidValue(Option.some(rule), { |
| 325 | title: "Invalid keybinding rule", | |
| 325 | message: "Invalid keybinding rule", | |
| 326 | 326 | }), |
| 327 | 327 | ), |
| 328 | 328 | Effect.map((resolved) => resolved), |
| ... | ... | @@ -334,7 +334,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( |
| 334 | 334 | if (!key) { |
| 335 | 335 | return yield* Effect.fail( |
| 336 | 336 | new SchemaIssue.InvalidValue(Option.some(resolved), { |
| 337 | title: "Resolved shortcut cannot be encoded to key string", | |
| 337 | message: "Resolved shortcut cannot be encoded to key string", | |
| 338 | 338 | }), |
| 339 | 339 | ); |
| 340 | 340 | } |
apps/server/src/persistence/Errors.ts+2| ... | ... | @@ -101,5 +101,7 @@ export type OrchestrationCommandReceiptRepositoryError = |
| 101 | 101 | | PersistenceDecodeError; |
| 102 | 102 | |
| 103 | 103 | export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; |
| 104 | export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; | |
| 105 | export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; | |
| 104 | 106 | |
| 105 | 107 | export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; |
apps/server/src/persistence/Layers/AuthPairingLinks.ts created+209| ... | ... | @@ -0,0 +1,209 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 3 | import { Effect, Layer, Schema } from "effect"; | |
| 4 | ||
| 5 | import { | |
| 6 | toPersistenceDecodeError, | |
| 7 | toPersistenceSqlError, | |
| 8 | type AuthPairingLinkRepositoryError, | |
| 9 | } from "../Errors.ts"; | |
| 10 | import { | |
| 11 | AuthPairingLinkRecord, | |
| 12 | AuthPairingLinkRepository, | |
| 13 | type AuthPairingLinkRepositoryShape, | |
| 14 | ConsumeAuthPairingLinkInput, | |
| 15 | CreateAuthPairingLinkInput, | |
| 16 | GetAuthPairingLinkByCredentialInput, | |
| 17 | ListActiveAuthPairingLinksInput, | |
| 18 | RevokeAuthPairingLinkInput, | |
| 19 | } from "../Services/AuthPairingLinks.ts"; | |
| 20 | ||
| 21 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 22 | return (cause: unknown): AuthPairingLinkRepositoryError => | |
| 23 | Schema.isSchemaError(cause) | |
| 24 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 25 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 26 | } | |
| 27 | ||
| 28 | const makeAuthPairingLinkRepository = Effect.gen(function* () { | |
| 29 | const sql = yield* SqlClient.SqlClient; | |
| 30 | ||
| 31 | const createPairingLinkRow = SqlSchema.void({ | |
| 32 | Request: CreateAuthPairingLinkInput, | |
| 33 | execute: (input) => | |
| 34 | sql` | |
| 35 | INSERT INTO auth_pairing_links ( | |
| 36 | id, | |
| 37 | credential, | |
| 38 | method, | |
| 39 | role, | |
| 40 | subject, | |
| 41 | label, | |
| 42 | created_at, | |
| 43 | expires_at, | |
| 44 | consumed_at, | |
| 45 | revoked_at | |
| 46 | ) | |
| 47 | VALUES ( | |
| 48 | ${input.id}, | |
| 49 | ${input.credential}, | |
| 50 | ${input.method}, | |
| 51 | ${input.role}, | |
| 52 | ${input.subject}, | |
| 53 | ${input.label}, | |
| 54 | ${input.createdAt}, | |
| 55 | ${input.expiresAt}, | |
| 56 | NULL, | |
| 57 | NULL | |
| 58 | ) | |
| 59 | `, | |
| 60 | }); | |
| 61 | ||
| 62 | const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ | |
| 63 | Request: ConsumeAuthPairingLinkInput, | |
| 64 | Result: AuthPairingLinkRecord, | |
| 65 | execute: ({ credential, consumedAt, now }) => | |
| 66 | sql` | |
| 67 | UPDATE auth_pairing_links | |
| 68 | SET consumed_at = ${consumedAt} | |
| 69 | WHERE credential = ${credential} | |
| 70 | AND revoked_at IS NULL | |
| 71 | AND consumed_at IS NULL | |
| 72 | AND expires_at > ${now} | |
| 73 | RETURNING | |
| 74 | id AS "id", | |
| 75 | credential AS "credential", | |
| 76 | method AS "method", | |
| 77 | role AS "role", | |
| 78 | subject AS "subject", | |
| 79 | label AS "label", | |
| 80 | created_at AS "createdAt", | |
| 81 | expires_at AS "expiresAt", | |
| 82 | consumed_at AS "consumedAt", | |
| 83 | revoked_at AS "revokedAt" | |
| 84 | `, | |
| 85 | }); | |
| 86 | ||
| 87 | const listActivePairingLinkRows = SqlSchema.findAll({ | |
| 88 | Request: ListActiveAuthPairingLinksInput, | |
| 89 | Result: AuthPairingLinkRecord, | |
| 90 | execute: ({ now }) => | |
| 91 | sql` | |
| 92 | SELECT | |
| 93 | id AS "id", | |
| 94 | credential AS "credential", | |
| 95 | method AS "method", | |
| 96 | role AS "role", | |
| 97 | subject AS "subject", | |
| 98 | label AS "label", | |
| 99 | created_at AS "createdAt", | |
| 100 | expires_at AS "expiresAt", | |
| 101 | consumed_at AS "consumedAt", | |
| 102 | revoked_at AS "revokedAt" | |
| 103 | FROM auth_pairing_links | |
| 104 | WHERE revoked_at IS NULL | |
| 105 | AND consumed_at IS NULL | |
| 106 | AND expires_at > ${now} | |
| 107 | ORDER BY created_at DESC, id DESC | |
| 108 | `, | |
| 109 | }); | |
| 110 | ||
| 111 | const revokePairingLinkRow = SqlSchema.findAll({ | |
| 112 | Request: RevokeAuthPairingLinkInput, | |
| 113 | Result: Schema.Struct({ id: Schema.String }), | |
| 114 | execute: ({ id, revokedAt }) => | |
| 115 | sql` | |
| 116 | UPDATE auth_pairing_links | |
| 117 | SET revoked_at = ${revokedAt} | |
| 118 | WHERE id = ${id} | |
| 119 | AND revoked_at IS NULL | |
| 120 | AND consumed_at IS NULL | |
| 121 | RETURNING id AS "id" | |
| 122 | `, | |
| 123 | }); | |
| 124 | ||
| 125 | const getPairingLinkRowByCredential = SqlSchema.findOneOption({ | |
| 126 | Request: GetAuthPairingLinkByCredentialInput, | |
| 127 | Result: AuthPairingLinkRecord, | |
| 128 | execute: ({ credential }) => | |
| 129 | sql` | |
| 130 | SELECT | |
| 131 | id AS "id", | |
| 132 | credential AS "credential", | |
| 133 | method AS "method", | |
| 134 | role AS "role", | |
| 135 | subject AS "subject", | |
| 136 | label AS "label", | |
| 137 | created_at AS "createdAt", | |
| 138 | expires_at AS "expiresAt", | |
| 139 | consumed_at AS "consumedAt", | |
| 140 | revoked_at AS "revokedAt" | |
| 141 | FROM auth_pairing_links | |
| 142 | WHERE credential = ${credential} | |
| 143 | `, | |
| 144 | }); | |
| 145 | ||
| 146 | const create: AuthPairingLinkRepositoryShape["create"] = (input) => | |
| 147 | createPairingLinkRow(input).pipe( | |
| 148 | Effect.mapError( | |
| 149 | toPersistenceSqlOrDecodeError( | |
| 150 | "AuthPairingLinkRepository.create:query", | |
| 151 | "AuthPairingLinkRepository.create:encodeRequest", | |
| 152 | ), | |
| 153 | ), | |
| 154 | ); | |
| 155 | ||
| 156 | const consumeAvailable: AuthPairingLinkRepositoryShape["consumeAvailable"] = (input) => | |
| 157 | consumeAvailablePairingLinkRow(input).pipe( | |
| 158 | Effect.mapError( | |
| 159 | toPersistenceSqlOrDecodeError( | |
| 160 | "AuthPairingLinkRepository.consumeAvailable:query", | |
| 161 | "AuthPairingLinkRepository.consumeAvailable:decodeRow", | |
| 162 | ), | |
| 163 | ), | |
| 164 | ); | |
| 165 | ||
| 166 | const listActive: AuthPairingLinkRepositoryShape["listActive"] = (input) => | |
| 167 | listActivePairingLinkRows(input).pipe( | |
| 168 | Effect.mapError( | |
| 169 | toPersistenceSqlOrDecodeError( | |
| 170 | "AuthPairingLinkRepository.listActive:query", | |
| 171 | "AuthPairingLinkRepository.listActive:decodeRows", | |
| 172 | ), | |
| 173 | ), | |
| 174 | ); | |
| 175 | ||
| 176 | const revoke: AuthPairingLinkRepositoryShape["revoke"] = (input) => | |
| 177 | revokePairingLinkRow(input).pipe( | |
| 178 | Effect.mapError( | |
| 179 | toPersistenceSqlOrDecodeError( | |
| 180 | "AuthPairingLinkRepository.revoke:query", | |
| 181 | "AuthPairingLinkRepository.revoke:decodeRows", | |
| 182 | ), | |
| 183 | ), | |
| 184 | Effect.map((rows) => rows.length > 0), | |
| 185 | ); | |
| 186 | ||
| 187 | const getByCredential: AuthPairingLinkRepositoryShape["getByCredential"] = (input) => | |
| 188 | getPairingLinkRowByCredential(input).pipe( | |
| 189 | Effect.mapError( | |
| 190 | toPersistenceSqlOrDecodeError( | |
| 191 | "AuthPairingLinkRepository.getByCredential:query", | |
| 192 | "AuthPairingLinkRepository.getByCredential:decodeRow", | |
| 193 | ), | |
| 194 | ), | |
| 195 | ); | |
| 196 | ||
| 197 | return { | |
| 198 | create, | |
| 199 | consumeAvailable, | |
| 200 | listActive, | |
| 201 | revoke, | |
| 202 | getByCredential, | |
| 203 | } satisfies AuthPairingLinkRepositoryShape; | |
| 204 | }); | |
| 205 | ||
| 206 | export const AuthPairingLinkRepositoryLive = Layer.effect( | |
| 207 | AuthPairingLinkRepository, | |
| 208 | makeAuthPairingLinkRepository, | |
| 209 | ); |
apps/server/src/persistence/Layers/AuthSessions.ts created+279| ... | ... | @@ -0,0 +1,279 @@ |
| 1 | import { AuthSessionId } from "@t3tools/contracts"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | |
| 4 | import { Effect, Layer, Option, Schema } from "effect"; | |
| 5 | ||
| 6 | import { | |
| 7 | toPersistenceDecodeError, | |
| 8 | toPersistenceSqlError, | |
| 9 | type AuthSessionRepositoryError, | |
| 10 | } from "../Errors.ts"; | |
| 11 | import { | |
| 12 | AuthSessionRecord, | |
| 13 | AuthSessionRepository, | |
| 14 | type AuthSessionRepositoryShape, | |
| 15 | CreateAuthSessionInput, | |
| 16 | GetAuthSessionByIdInput, | |
| 17 | ListActiveAuthSessionsInput, | |
| 18 | RevokeAuthSessionInput, | |
| 19 | RevokeOtherAuthSessionsInput, | |
| 20 | SetAuthSessionLastConnectedAtInput, | |
| 21 | } from "../Services/AuthSessions.ts"; | |
| 22 | ||
| 23 | const AuthSessionDbRow = Schema.Struct({ | |
| 24 | sessionId: AuthSessionId, | |
| 25 | subject: Schema.String, | |
| 26 | role: Schema.Literals(["owner", "client"]), | |
| 27 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | |
| 28 | clientLabel: Schema.NullOr(Schema.String), | |
| 29 | clientIpAddress: Schema.NullOr(Schema.String), | |
| 30 | clientUserAgent: Schema.NullOr(Schema.String), | |
| 31 | clientDeviceType: Schema.Literals(["desktop", "mobile", "tablet", "bot", "unknown"]), | |
| 32 | clientOs: Schema.NullOr(Schema.String), | |
| 33 | clientBrowser: Schema.NullOr(Schema.String), | |
| 34 | issuedAt: Schema.DateTimeUtcFromString, | |
| 35 | expiresAt: Schema.DateTimeUtcFromString, | |
| 36 | lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | |
| 37 | revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | |
| 38 | }); | |
| 39 | ||
| 40 | function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): typeof AuthSessionRecord.Type { | |
| 41 | return { | |
| 42 | sessionId: row.sessionId, | |
| 43 | subject: row.subject, | |
| 44 | role: row.role, | |
| 45 | method: row.method, | |
| 46 | client: { | |
| 47 | label: row.clientLabel, | |
| 48 | ipAddress: row.clientIpAddress, | |
| 49 | userAgent: row.clientUserAgent, | |
| 50 | deviceType: row.clientDeviceType, | |
| 51 | os: row.clientOs, | |
| 52 | browser: row.clientBrowser, | |
| 53 | }, | |
| 54 | issuedAt: row.issuedAt, | |
| 55 | expiresAt: row.expiresAt, | |
| 56 | lastConnectedAt: row.lastConnectedAt, | |
| 57 | revokedAt: row.revokedAt, | |
| 58 | }; | |
| 59 | } | |
| 60 | ||
| 61 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | |
| 62 | return (cause: unknown): AuthSessionRepositoryError => | |
| 63 | Schema.isSchemaError(cause) | |
| 64 | ? toPersistenceDecodeError(decodeOperation)(cause) | |
| 65 | : toPersistenceSqlError(sqlOperation)(cause); | |
| 66 | } | |
| 67 | ||
| 68 | const makeAuthSessionRepository = Effect.gen(function* () { | |
| 69 | const sql = yield* SqlClient.SqlClient; | |
| 70 | ||
| 71 | const createSessionRow = SqlSchema.void({ | |
| 72 | Request: CreateAuthSessionInput, | |
| 73 | execute: (input) => | |
| 74 | sql` | |
| 75 | INSERT INTO auth_sessions ( | |
| 76 | session_id, | |
| 77 | subject, | |
| 78 | role, | |
| 79 | method, | |
| 80 | client_label, | |
| 81 | client_ip_address, | |
| 82 | client_user_agent, | |
| 83 | client_device_type, | |
| 84 | client_os, | |
| 85 | client_browser, | |
| 86 | issued_at, | |
| 87 | expires_at, | |
| 88 | revoked_at | |
| 89 | ) | |
| 90 | VALUES ( | |
| 91 | ${input.sessionId}, | |
| 92 | ${input.subject}, | |
| 93 | ${input.role}, | |
| 94 | ${input.method}, | |
| 95 | ${input.client.label}, | |
| 96 | ${input.client.ipAddress}, | |
| 97 | ${input.client.userAgent}, | |
| 98 | ${input.client.deviceType}, | |
| 99 | ${input.client.os}, | |
| 100 | ${input.client.browser}, | |
| 101 | ${input.issuedAt}, | |
| 102 | ${input.expiresAt}, | |
| 103 | NULL | |
| 104 | ) | |
| 105 | `, | |
| 106 | }); | |
| 107 | ||
| 108 | const getSessionRowById = SqlSchema.findOneOption({ | |
| 109 | Request: GetAuthSessionByIdInput, | |
| 110 | Result: AuthSessionDbRow, | |
| 111 | execute: ({ sessionId }) => | |
| 112 | sql` | |
| 113 | SELECT | |
| 114 | session_id AS "sessionId", | |
| 115 | subject AS "subject", | |
| 116 | role AS "role", | |
| 117 | method AS "method", | |
| 118 | client_label AS "clientLabel", | |
| 119 | client_ip_address AS "clientIpAddress", | |
| 120 | client_user_agent AS "clientUserAgent", | |
| 121 | client_device_type AS "clientDeviceType", | |
| 122 | client_os AS "clientOs", | |
| 123 | client_browser AS "clientBrowser", | |
| 124 | issued_at AS "issuedAt", | |
| 125 | expires_at AS "expiresAt", | |
| 126 | last_connected_at AS "lastConnectedAt", | |
| 127 | revoked_at AS "revokedAt" | |
| 128 | FROM auth_sessions | |
| 129 | WHERE session_id = ${sessionId} | |
| 130 | `, | |
| 131 | }); | |
| 132 | ||
| 133 | const listActiveSessionRows = SqlSchema.findAll({ | |
| 134 | Request: ListActiveAuthSessionsInput, | |
| 135 | Result: AuthSessionDbRow, | |
| 136 | execute: ({ now }) => | |
| 137 | sql` | |
| 138 | SELECT | |
| 139 | session_id AS "sessionId", | |
| 140 | subject AS "subject", | |
| 141 | role AS "role", | |
| 142 | method AS "method", | |
| 143 | client_label AS "clientLabel", | |
| 144 | client_ip_address AS "clientIpAddress", | |
| 145 | client_user_agent AS "clientUserAgent", | |
| 146 | client_device_type AS "clientDeviceType", | |
| 147 | client_os AS "clientOs", | |
| 148 | client_browser AS "clientBrowser", | |
| 149 | issued_at AS "issuedAt", | |
| 150 | expires_at AS "expiresAt", | |
| 151 | last_connected_at AS "lastConnectedAt", | |
| 152 | revoked_at AS "revokedAt" | |
| 153 | FROM auth_sessions | |
| 154 | WHERE revoked_at IS NULL | |
| 155 | AND expires_at > ${now} | |
| 156 | ORDER BY issued_at DESC, session_id DESC | |
| 157 | `, | |
| 158 | }); | |
| 159 | ||
| 160 | const setLastConnectedAtRow = SqlSchema.void({ | |
| 161 | Request: SetAuthSessionLastConnectedAtInput, | |
| 162 | execute: ({ sessionId, lastConnectedAt }) => | |
| 163 | sql` | |
| 164 | UPDATE auth_sessions | |
| 165 | SET last_connected_at = ${lastConnectedAt} | |
| 166 | WHERE session_id = ${sessionId} | |
| 167 | AND revoked_at IS NULL | |
| 168 | `, | |
| 169 | }); | |
| 170 | ||
| 171 | const revokeSessionRows = SqlSchema.findAll({ | |
| 172 | Request: RevokeAuthSessionInput, | |
| 173 | Result: Schema.Struct({ sessionId: AuthSessionId }), | |
| 174 | execute: ({ sessionId, revokedAt }) => | |
| 175 | sql` | |
| 176 | UPDATE auth_sessions | |
| 177 | SET revoked_at = ${revokedAt} | |
| 178 | WHERE session_id = ${sessionId} | |
| 179 | AND revoked_at IS NULL | |
| 180 | RETURNING session_id AS "sessionId" | |
| 181 | `, | |
| 182 | }); | |
| 183 | ||
| 184 | const revokeOtherSessionRows = SqlSchema.findAll({ | |
| 185 | Request: RevokeOtherAuthSessionsInput, | |
| 186 | Result: Schema.Struct({ sessionId: AuthSessionId }), | |
| 187 | execute: ({ currentSessionId, revokedAt }) => | |
| 188 | sql` | |
| 189 | UPDATE auth_sessions | |
| 190 | SET revoked_at = ${revokedAt} | |
| 191 | WHERE session_id <> ${currentSessionId} | |
| 192 | AND revoked_at IS NULL | |
| 193 | RETURNING session_id AS "sessionId" | |
| 194 | `, | |
| 195 | }); | |
| 196 | ||
| 197 | const create: AuthSessionRepositoryShape["create"] = (input) => | |
| 198 | createSessionRow(input).pipe( | |
| 199 | Effect.mapError( | |
| 200 | toPersistenceSqlOrDecodeError( | |
| 201 | "AuthSessionRepository.create:query", | |
| 202 | "AuthSessionRepository.create:encodeRequest", | |
| 203 | ), | |
| 204 | ), | |
| 205 | ); | |
| 206 | ||
| 207 | const getById: AuthSessionRepositoryShape["getById"] = (input) => | |
| 208 | getSessionRowById(input).pipe( | |
| 209 | Effect.mapError( | |
| 210 | toPersistenceSqlOrDecodeError( | |
| 211 | "AuthSessionRepository.getById:query", | |
| 212 | "AuthSessionRepository.getById:decodeRow", | |
| 213 | ), | |
| 214 | ), | |
| 215 | Effect.flatMap((rowOption) => | |
| 216 | Option.match(rowOption, { | |
| 217 | onNone: () => Effect.succeed(Option.none()), | |
| 218 | onSome: (row) => Effect.succeed(Option.some(toAuthSessionRecord(row))), | |
| 219 | }), | |
| 220 | ), | |
| 221 | ); | |
| 222 | ||
| 223 | const listActive: AuthSessionRepositoryShape["listActive"] = (input) => | |
| 224 | listActiveSessionRows(input).pipe( | |
| 225 | Effect.mapError( | |
| 226 | toPersistenceSqlOrDecodeError( | |
| 227 | "AuthSessionRepository.listActive:query", | |
| 228 | "AuthSessionRepository.listActive:decodeRows", | |
| 229 | ), | |
| 230 | ), | |
| 231 | Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))), | |
| 232 | ); | |
| 233 | ||
| 234 | const revoke: AuthSessionRepositoryShape["revoke"] = (input) => | |
| 235 | revokeSessionRows(input).pipe( | |
| 236 | Effect.mapError( | |
| 237 | toPersistenceSqlOrDecodeError( | |
| 238 | "AuthSessionRepository.revoke:query", | |
| 239 | "AuthSessionRepository.revoke:decodeRows", | |
| 240 | ), | |
| 241 | ), | |
| 242 | Effect.map((rows) => rows.length > 0), | |
| 243 | ); | |
| 244 | ||
| 245 | const revokeAllExcept: AuthSessionRepositoryShape["revokeAllExcept"] = (input) => | |
| 246 | revokeOtherSessionRows(input).pipe( | |
| 247 | Effect.mapError( | |
| 248 | toPersistenceSqlOrDecodeError( | |
| 249 | "AuthSessionRepository.revokeAllExcept:query", | |
| 250 | "AuthSessionRepository.revokeAllExcept:decodeRows", | |
| 251 | ), | |
| 252 | ), | |
| 253 | Effect.map((rows) => rows.map((row) => row.sessionId)), | |
| 254 | ); | |
| 255 | ||
| 256 | const setLastConnectedAt: AuthSessionRepositoryShape["setLastConnectedAt"] = (input) => | |
| 257 | setLastConnectedAtRow(input).pipe( | |
| 258 | Effect.mapError( | |
| 259 | toPersistenceSqlOrDecodeError( | |
| 260 | "AuthSessionRepository.setLastConnectedAt:query", | |
| 261 | "AuthSessionRepository.setLastConnectedAt:encodeRequest", | |
| 262 | ), | |
| 263 | ), | |
| 264 | ); | |
| 265 | ||
| 266 | return { | |
| 267 | create, | |
| 268 | getById, | |
| 269 | listActive, | |
| 270 | revoke, | |
| 271 | revokeAllExcept, | |
| 272 | setLastConnectedAt, | |
| 273 | } satisfies AuthSessionRepositoryShape; | |
| 274 | }); | |
| 275 | ||
| 276 | export const AuthSessionRepositoryLive = Layer.effect( | |
| 277 | AuthSessionRepository, | |
| 278 | makeAuthSessionRepository, | |
| 279 | ); |
apps/server/src/persistence/Migrations.ts+6| ... | ... | @@ -32,6 +32,9 @@ import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; |
| 32 | 32 | import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; |
| 33 | 33 | import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; |
| 34 | 34 | import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; |
| 35 | import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; | |
| 36 | import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; | |
| 37 | import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; | |
| 35 | 38 | |
| 36 | 39 | /** |
| 37 | 40 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -63,6 +66,9 @@ export const migrationEntries = [ |
| 63 | 66 | [17, "ProjectionThreadsArchivedAt", Migration0017], |
| 64 | 67 | [18, "ProjectionThreadsArchivedAtIndex", Migration0018], |
| 65 | 68 | [19, "ProjectionSnapshotLookupIndexes", Migration0019], |
| 69 | [20, "AuthAccessManagement", Migration0020], | |
| 70 | [21, "AuthSessionClientMetadata", Migration0021], | |
| 71 | [22, "AuthSessionLastConnectedAt", Migration0022], | |
| 66 | 72 | ] as const; |
| 67 | 73 | |
| 68 | 74 | export const makeMigrationLoader = (throughId?: number) => |
apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | yield* sql` | |
| 8 | CREATE TABLE IF NOT EXISTS auth_pairing_links ( | |
| 9 | id TEXT PRIMARY KEY, | |
| 10 | credential TEXT NOT NULL UNIQUE, | |
| 11 | method TEXT NOT NULL, | |
| 12 | role TEXT NOT NULL, | |
| 13 | subject TEXT NOT NULL, | |
| 14 | created_at TEXT NOT NULL, | |
| 15 | expires_at TEXT NOT NULL, | |
| 16 | consumed_at TEXT, | |
| 17 | revoked_at TEXT | |
| 18 | ) | |
| 19 | `; | |
| 20 | ||
| 21 | yield* sql` | |
| 22 | CREATE INDEX IF NOT EXISTS idx_auth_pairing_links_active | |
| 23 | ON auth_pairing_links(revoked_at, consumed_at, expires_at) | |
| 24 | `; | |
| 25 | ||
| 26 | yield* sql` | |
| 27 | CREATE TABLE IF NOT EXISTS auth_sessions ( | |
| 28 | session_id TEXT PRIMARY KEY, | |
| 29 | subject TEXT NOT NULL, | |
| 30 | role TEXT NOT NULL, | |
| 31 | method TEXT NOT NULL, | |
| 32 | issued_at TEXT NOT NULL, | |
| 33 | expires_at TEXT NOT NULL, | |
| 34 | revoked_at TEXT | |
| 35 | ) | |
| 36 | `; | |
| 37 | ||
| 38 | yield* sql` | |
| 39 | CREATE INDEX IF NOT EXISTS idx_auth_sessions_active | |
| 40 | ON auth_sessions(revoked_at, expires_at, issued_at) | |
| 41 | `; | |
| 42 | }); |
apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts created+62| ... | ... | @@ -0,0 +1,62 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | const pairingLinkColumns = yield* sql<{ readonly name: string }>` | |
| 8 | PRAGMA table_info(auth_pairing_links) | |
| 9 | `; | |
| 10 | if (!pairingLinkColumns.some((column) => column.name === "label")) { | |
| 11 | yield* sql` | |
| 12 | ALTER TABLE auth_pairing_links | |
| 13 | ADD COLUMN label TEXT | |
| 14 | `; | |
| 15 | } | |
| 16 | ||
| 17 | const sessionColumns = yield* sql<{ readonly name: string }>` | |
| 18 | PRAGMA table_info(auth_sessions) | |
| 19 | `; | |
| 20 | ||
| 21 | if (!sessionColumns.some((column) => column.name === "client_label")) { | |
| 22 | yield* sql` | |
| 23 | ALTER TABLE auth_sessions | |
| 24 | ADD COLUMN client_label TEXT | |
| 25 | `; | |
| 26 | } | |
| 27 | ||
| 28 | if (!sessionColumns.some((column) => column.name === "client_ip_address")) { | |
| 29 | yield* sql` | |
| 30 | ALTER TABLE auth_sessions | |
| 31 | ADD COLUMN client_ip_address TEXT | |
| 32 | `; | |
| 33 | } | |
| 34 | ||
| 35 | if (!sessionColumns.some((column) => column.name === "client_user_agent")) { | |
| 36 | yield* sql` | |
| 37 | ALTER TABLE auth_sessions | |
| 38 | ADD COLUMN client_user_agent TEXT | |
| 39 | `; | |
| 40 | } | |
| 41 | ||
| 42 | if (!sessionColumns.some((column) => column.name === "client_device_type")) { | |
| 43 | yield* sql` | |
| 44 | ALTER TABLE auth_sessions | |
| 45 | ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' | |
| 46 | `; | |
| 47 | } | |
| 48 | ||
| 49 | if (!sessionColumns.some((column) => column.name === "client_os")) { | |
| 50 | yield* sql` | |
| 51 | ALTER TABLE auth_sessions | |
| 52 | ADD COLUMN client_os TEXT | |
| 53 | `; | |
| 54 | } | |
| 55 | ||
| 56 | if (!sessionColumns.some((column) => column.name === "client_browser")) { | |
| 57 | yield* sql` | |
| 58 | ALTER TABLE auth_sessions | |
| 59 | ADD COLUMN client_browser TEXT | |
| 60 | `; | |
| 61 | } | |
| 62 | }); |
apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts created+17| ... | ... | @@ -0,0 +1,17 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export default Effect.gen(function* () { | |
| 5 | const sql = yield* SqlClient.SqlClient; | |
| 6 | ||
| 7 | const sessionColumns = yield* sql<{ readonly name: string }>` | |
| 8 | PRAGMA table_info(auth_sessions) | |
| 9 | `; | |
| 10 | ||
| 11 | if (!sessionColumns.some((column) => column.name === "last_connected_at")) { | |
| 12 | yield* sql` | |
| 13 | ALTER TABLE auth_sessions | |
| 14 | ADD COLUMN last_connected_at TEXT | |
| 15 | `; | |
| 16 | } | |
| 17 | }); |
apps/server/src/persistence/Services/AuthPairingLinks.ts created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | import { Option, Schema, ServiceMap } from "effect"; | |
| 2 | import type { Effect } from "effect"; | |
| 3 | ||
| 4 | import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; | |
| 5 | ||
| 6 | export const AuthPairingLinkRecord = Schema.Struct({ | |
| 7 | id: Schema.String, | |
| 8 | credential: Schema.String, | |
| 9 | method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), | |
| 10 | role: Schema.Literals(["owner", "client"]), | |
| 11 | subject: Schema.String, | |
| 12 | label: Schema.NullOr(Schema.String), | |
| 13 | createdAt: Schema.DateTimeUtcFromString, | |
| 14 | expiresAt: Schema.DateTimeUtcFromString, | |
| 15 | consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | |
| 16 | revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | |
| 17 | }); | |
| 18 | export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type; | |
| 19 | ||
| 20 | export const CreateAuthPairingLinkInput = Schema.Struct({ | |
| 21 | id: Schema.String, | |
| 22 | credential: Schema.String, | |
| 23 | method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), | |
| 24 | role: Schema.Literals(["owner", "client"]), | |
| 25 | subject: Schema.String, | |
| 26 | label: Schema.NullOr(Schema.String), | |
| 27 | createdAt: Schema.DateTimeUtcFromString, | |
| 28 | expiresAt: Schema.DateTimeUtcFromString, | |
| 29 | }); | |
| 30 | export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; | |
| 31 | ||
| 32 | export const ConsumeAuthPairingLinkInput = Schema.Struct({ | |
| 33 | credential: Schema.String, | |
| 34 | consumedAt: Schema.DateTimeUtcFromString, | |
| 35 | now: Schema.DateTimeUtcFromString, | |
| 36 | }); | |
| 37 | export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type; | |
| 38 | ||
| 39 | export const ListActiveAuthPairingLinksInput = Schema.Struct({ | |
| 40 | now: Schema.DateTimeUtcFromString, | |
| 41 | }); | |
| 42 | export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type; | |
| 43 | ||
| 44 | export const RevokeAuthPairingLinkInput = Schema.Struct({ | |
| 45 | id: Schema.String, | |
| 46 | revokedAt: Schema.DateTimeUtcFromString, | |
| 47 | }); | |
| 48 | export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type; | |
| 49 | ||
| 50 | export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ | |
| 51 | credential: Schema.String, | |
| 52 | }); | |
| 53 | export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; | |
| 54 | ||
| 55 | export interface AuthPairingLinkRepositoryShape { | |
| 56 | readonly create: ( | |
| 57 | input: CreateAuthPairingLinkInput, | |
| 58 | ) => Effect.Effect<void, AuthPairingLinkRepositoryError>; | |
| 59 | readonly consumeAvailable: ( | |
| 60 | input: ConsumeAuthPairingLinkInput, | |
| 61 | ) => Effect.Effect<Option.Option<AuthPairingLinkRecord>, AuthPairingLinkRepositoryError>; | |
| 62 | readonly listActive: ( | |
| 63 | input: ListActiveAuthPairingLinksInput, | |
| 64 | ) => Effect.Effect<ReadonlyArray<AuthPairingLinkRecord>, AuthPairingLinkRepositoryError>; | |
| 65 | readonly revoke: ( | |
| 66 | input: RevokeAuthPairingLinkInput, | |
| 67 | ) => Effect.Effect<boolean, AuthPairingLinkRepositoryError>; | |
| 68 | readonly getByCredential: ( | |
| 69 | input: GetAuthPairingLinkByCredentialInput, | |
| 70 | ) => Effect.Effect<Option.Option<AuthPairingLinkRecord>, AuthPairingLinkRepositoryError>; | |
| 71 | } | |
| 72 | ||
| 73 | export class AuthPairingLinkRepository extends ServiceMap.Service< | |
| 74 | AuthPairingLinkRepository, | |
| 75 | AuthPairingLinkRepositoryShape | |
| 76 | >()("t3/persistence/Services/AuthPairingLinks/AuthPairingLinkRepository") {} |
apps/server/src/persistence/Services/AuthSessions.ts created+93| ... | ... | @@ -0,0 +1,93 @@ |
| 1 | import { AuthClientMetadataDeviceType, AuthSessionId } from "@t3tools/contracts"; | |
| 2 | import { Option, Schema, ServiceMap } from "effect"; | |
| 3 | import type { Effect } from "effect"; | |
| 4 | ||
| 5 | import type { AuthSessionRepositoryError } from "../Errors.ts"; | |
| 6 | ||
| 7 | export const AuthSessionClientMetadataRecord = Schema.Struct({ | |
| 8 | label: Schema.NullOr(Schema.String), | |
| 9 | ipAddress: Schema.NullOr(Schema.String), | |
| 10 | userAgent: Schema.NullOr(Schema.String), | |
| 11 | deviceType: AuthClientMetadataDeviceType, | |
| 12 | os: Schema.NullOr(Schema.String), | |
| 13 | browser: Schema.NullOr(Schema.String), | |
| 14 | }); | |
| 15 | export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type; | |
| 16 | ||
| 17 | export const AuthSessionRecord = Schema.Struct({ | |
| 18 | sessionId: AuthSessionId, | |
| 19 | subject: Schema.String, | |
| 20 | role: Schema.Literals(["owner", "client"]), | |
| 21 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | |
| 22 | client: AuthSessionClientMetadataRecord, | |
| 23 | issuedAt: Schema.DateTimeUtcFromString, | |
| 24 | expiresAt: Schema.DateTimeUtcFromString, | |
| 25 | lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | |
| 26 | revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | |
| 27 | }); | |
| 28 | export type AuthSessionRecord = typeof AuthSessionRecord.Type; | |
| 29 | ||
| 30 | export const CreateAuthSessionInput = Schema.Struct({ | |
| 31 | sessionId: AuthSessionId, | |
| 32 | subject: Schema.String, | |
| 33 | role: Schema.Literals(["owner", "client"]), | |
| 34 | method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | |
| 35 | client: AuthSessionClientMetadataRecord, | |
| 36 | issuedAt: Schema.DateTimeUtcFromString, | |
| 37 | expiresAt: Schema.DateTimeUtcFromString, | |
| 38 | }); | |
| 39 | export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; | |
| 40 | ||
| 41 | export const GetAuthSessionByIdInput = Schema.Struct({ | |
| 42 | sessionId: AuthSessionId, | |
| 43 | }); | |
| 44 | export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; | |
| 45 | ||
| 46 | export const ListActiveAuthSessionsInput = Schema.Struct({ | |
| 47 | now: Schema.DateTimeUtcFromString, | |
| 48 | }); | |
| 49 | export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; | |
| 50 | ||
| 51 | export const RevokeAuthSessionInput = Schema.Struct({ | |
| 52 | sessionId: AuthSessionId, | |
| 53 | revokedAt: Schema.DateTimeUtcFromString, | |
| 54 | }); | |
| 55 | export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; | |
| 56 | ||
| 57 | export const RevokeOtherAuthSessionsInput = Schema.Struct({ | |
| 58 | currentSessionId: AuthSessionId, | |
| 59 | revokedAt: Schema.DateTimeUtcFromString, | |
| 60 | }); | |
| 61 | export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; | |
| 62 | ||
| 63 | export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ | |
| 64 | sessionId: AuthSessionId, | |
| 65 | lastConnectedAt: Schema.DateTimeUtcFromString, | |
| 66 | }); | |
| 67 | export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; | |
| 68 | ||
| 69 | export interface AuthSessionRepositoryShape { | |
| 70 | readonly create: ( | |
| 71 | input: CreateAuthSessionInput, | |
| 72 | ) => Effect.Effect<void, AuthSessionRepositoryError>; | |
| 73 | readonly getById: ( | |
| 74 | input: GetAuthSessionByIdInput, | |
| 75 | ) => Effect.Effect<Option.Option<AuthSessionRecord>, AuthSessionRepositoryError>; | |
| 76 | readonly listActive: ( | |
| 77 | input: ListActiveAuthSessionsInput, | |
| 78 | ) => Effect.Effect<ReadonlyArray<AuthSessionRecord>, AuthSessionRepositoryError>; | |
| 79 | readonly revoke: ( | |
| 80 | input: RevokeAuthSessionInput, | |
| 81 | ) => Effect.Effect<boolean, AuthSessionRepositoryError>; | |
| 82 | readonly revokeAllExcept: ( | |
| 83 | input: RevokeOtherAuthSessionsInput, | |
| 84 | ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>; | |
| 85 | readonly setLastConnectedAt: ( | |
| 86 | input: SetAuthSessionLastConnectedAtInput, | |
| 87 | ) => Effect.Effect<void, AuthSessionRepositoryError>; | |
| 88 | } | |
| 89 | ||
| 90 | export class AuthSessionRepository extends ServiceMap.Service< | |
| 91 | AuthSessionRepository, | |
| 92 | AuthSessionRepositoryShape | |
| 93 | >()("t3/persistence/Services/AuthSessions/AuthSessionRepository") {} |
apps/server/src/server.test.ts+815-53| ... | ... | @@ -43,6 +43,7 @@ import { |
| 43 | 43 | } from "effect/unstable/http"; |
| 44 | 44 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 45 | 45 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; |
| 46 | import * as Socket from "effect/unstable/socket/Socket"; | |
| 46 | 47 | import { vi } from "vitest"; |
| 47 | 48 | |
| 48 | 49 | import type { ServerConfigShape } from "./config.ts"; |
| ... | ... | @@ -68,6 +69,7 @@ import { |
| 68 | 69 | type ProjectionSnapshotQueryShape, |
| 69 | 70 | } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 70 | 71 | import { PersistenceSqlError } from "./persistence/Errors.ts"; |
| 72 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; | |
| 71 | 73 | import { |
| 72 | 74 | ProviderRegistry, |
| 73 | 75 | type ProviderRegistryShape, |
| ... | ... | @@ -96,9 +98,12 @@ import { |
| 96 | 98 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; |
| 97 | 99 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 98 | 100 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 101 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | |
| 102 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | |
| 99 | 103 | |
| 100 | 104 | const defaultProjectId = ProjectId.makeUnsafe("project-default"); |
| 101 | 105 | const defaultThreadId = ThreadId.makeUnsafe("thread-default"); |
| 106 | const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; | |
| 102 | 107 | const defaultModelSelection = { |
| 103 | 108 | provider: "codex", |
| 104 | 109 | model: "gpt-5-codex", |
| ... | ... | @@ -115,7 +120,6 @@ const testEnvironmentDescriptor = { |
| 115 | 120 | repositoryIdentity: true, |
| 116 | 121 | }, |
| 117 | 122 | }; |
| 118 | ||
| 119 | 123 | const makeDefaultOrchestrationReadModel = () => { |
| 120 | 124 | const now = new Date().toISOString(); |
| 121 | 125 | return { |
| ... | ... | @@ -174,6 +178,11 @@ const browserOtlpTracingLayer = Layer.mergeAll( |
| 174 | 178 | Layer.succeed(HttpClient.TracerDisabledWhen, () => true), |
| 175 | 179 | ); |
| 176 | 180 | |
| 181 | const authTestLayer = ServerAuthLive.pipe( | |
| 182 | Layer.provide(SqlitePersistenceMemory), | |
| 183 | Layer.provide(ServerSecretStoreLive), | |
| 184 | ); | |
| 185 | ||
| 177 | 186 | const makeBrowserOtlpPayload = (spanName: string) => |
| 178 | 187 | Effect.gen(function* () { |
| 179 | 188 | const collector = yield* Effect.acquireRelease( |
| ... | ... | @@ -313,7 +322,7 @@ const buildAppUnderTest = (options?: { |
| 313 | 322 | otlpMetricsUrl: undefined, |
| 314 | 323 | otlpExportIntervalMs: 10_000, |
| 315 | 324 | otlpServiceName: "t3-server", |
| 316 | mode: "web", | |
| 325 | mode: "desktop", | |
| 317 | 326 | port: 0, |
| 318 | 327 | host: "127.0.0.1", |
| 319 | 328 | cwd: process.cwd(), |
| ... | ... | @@ -322,7 +331,7 @@ const buildAppUnderTest = (options?: { |
| 322 | 331 | staticDir: undefined, |
| 323 | 332 | devUrl, |
| 324 | 333 | noBrowser: true, |
| 325 | authToken: undefined, | |
| 334 | desktopBootstrapToken: defaultDesktopBootstrapToken, | |
| 326 | 335 | autoBootstrapProjectFromCwd: false, |
| 327 | 336 | logWebSocketEvents: false, |
| 328 | 337 | ...options?.config, |
| ... | ... | @@ -333,12 +342,16 @@ const buildAppUnderTest = (options?: { |
| 333 | 342 | }); |
| 334 | 343 | const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); |
| 335 | 344 | |
| 336 | const appLayer = HttpRouter.serve(makeRoutesLayer, { | |
| 345 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { | |
| 337 | 346 | disableListenLog: true, |
| 338 | 347 | disableLogger: true, |
| 339 | 348 | }).pipe( |
| 340 | 349 | Layer.provide( |
| 341 | 350 | Layer.mock(Keybindings)({ |
| 351 | loadConfigState: Effect.succeed({ | |
| 352 | keybindings: [], | |
| 353 | issues: [], | |
| 354 | }), | |
| 342 | 355 | streamChanges: Stream.empty, |
| 343 | 356 | ...options?.layers?.keybindings, |
| 344 | 357 | }), |
| ... | ... | @@ -418,6 +431,9 @@ const buildAppUnderTest = (options?: { |
| 418 | 431 | ...options?.layers?.checkpointDiffQuery, |
| 419 | 432 | }), |
| 420 | 433 | ), |
| 434 | ); | |
| 435 | ||
| 436 | const appLayer = servedRoutesLayer.pipe( | |
| 421 | 437 | Layer.provide( |
| 422 | 438 | Layer.mock(BrowserTraceCollector)({ |
| 423 | 439 | record: () => Effect.void, |
| ... | ... | @@ -453,6 +469,7 @@ const buildAppUnderTest = (options?: { |
| 453 | 469 | ...options?.layers?.repositoryIdentityResolver, |
| 454 | 470 | }), |
| 455 | 471 | ), |
| 472 | Layer.provideMerge(authTestLayer), | |
| 456 | 473 | Layer.provide(workspaceAndProjectServicesLayer), |
| 457 | 474 | Layer.provideMerge(FetchHttpClient.layer), |
| 458 | 475 | Layer.provide(layerConfig), |
| ... | ... | @@ -462,11 +479,37 @@ const buildAppUnderTest = (options?: { |
| 462 | 479 | return config; |
| 463 | 480 | }); |
| 464 | 481 | |
| 465 | const wsRpcProtocolLayer = (wsUrl: string) => | |
| 466 | RpcClient.layerProtocolSocket().pipe( | |
| 467 | Layer.provide(NodeSocket.layerWebSocket(wsUrl)), | |
| 482 | const parseSessionCookieFromWsUrl = ( | |
| 483 | wsUrl: string, | |
| 484 | ): { readonly cookie: string | null; readonly url: string } => { | |
| 485 | const next = new URL(wsUrl); | |
| 486 | const cookie = next.hash.startsWith("#cookie=") | |
| 487 | ? decodeURIComponent(next.hash.slice("#cookie=".length)) | |
| 488 | : null; | |
| 489 | next.hash = ""; | |
| 490 | return { | |
| 491 | cookie, | |
| 492 | url: next.toString(), | |
| 493 | }; | |
| 494 | }; | |
| 495 | ||
| 496 | const wsRpcProtocolLayer = (wsUrl: string) => { | |
| 497 | const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl); | |
| 498 | const webSocketConstructorLayer = Layer.succeed( | |
| 499 | Socket.WebSocketConstructor, | |
| 500 | (socketUrl, protocols) => | |
| 501 | new NodeSocket.NodeWS.WebSocket( | |
| 502 | socketUrl, | |
| 503 | protocols, | |
| 504 | cookie ? { headers: { cookie } } : undefined, | |
| 505 | ) as unknown as globalThis.WebSocket, | |
| 506 | ); | |
| 507 | ||
| 508 | return RpcClient.layerProtocolSocket().pipe( | |
| 509 | Layer.provide(Socket.layerWebSocket(url).pipe(Layer.provide(webSocketConstructorLayer))), | |
| 468 | 510 | Layer.provide(RpcSerialization.layerJson), |
| 469 | 511 | ); |
| 512 | }; | |
| 470 | 513 | |
| 471 | 514 | const makeWsRpcClient = RpcClient.make(WsRpcGroup); |
| 472 | 515 | type WsRpcClient = |
| ... | ... | @@ -477,6 +520,13 @@ const withWsRpcClient = <A, E, R>( |
| 477 | 520 | f: (client: WsRpcClient) => Effect.Effect<A, E, R>, |
| 478 | 521 | ) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); |
| 479 | 522 | |
| 523 | const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => { | |
| 524 | const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url); | |
| 525 | const next = new URL(url, "http://localhost"); | |
| 526 | next.hash = `cookie=${encodeURIComponent(sessionCookieHeader)}`; | |
| 527 | return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; | |
| 528 | }; | |
| 529 | ||
| 480 | 530 | const getHttpServerUrl = (pathname = "") => |
| 481 | 531 | Effect.gen(function* () { |
| 482 | 532 | const server = yield* HttpServer.HttpServer; |
| ... | ... | @@ -484,11 +534,130 @@ const getHttpServerUrl = (pathname = "") => |
| 484 | 534 | return `http://127.0.0.1:${address.port}${pathname}`; |
| 485 | 535 | }); |
| 486 | 536 | |
| 487 | const getWsServerUrl = (pathname = "") => | |
| 537 | const bootstrapBrowserSession = ( | |
| 538 | credential = defaultDesktopBootstrapToken, | |
| 539 | options?: { | |
| 540 | readonly headers?: Record<string, string>; | |
| 541 | }, | |
| 542 | ) => | |
| 543 | Effect.gen(function* () { | |
| 544 | const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap"); | |
| 545 | const response = yield* Effect.promise(() => | |
| 546 | fetch(bootstrapUrl, { | |
| 547 | method: "POST", | |
| 548 | headers: { | |
| 549 | "content-type": "application/json", | |
| 550 | ...options?.headers, | |
| 551 | }, | |
| 552 | body: JSON.stringify({ | |
| 553 | credential, | |
| 554 | }), | |
| 555 | }), | |
| 556 | ); | |
| 557 | const body = (yield* Effect.promise(() => response.json())) as { | |
| 558 | readonly authenticated: boolean; | |
| 559 | readonly sessionMethod: string; | |
| 560 | readonly expiresAt: string; | |
| 561 | }; | |
| 562 | return { | |
| 563 | response, | |
| 564 | body, | |
| 565 | cookie: response.headers.get("set-cookie"), | |
| 566 | }; | |
| 567 | }); | |
| 568 | ||
| 569 | const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) => | |
| 570 | Effect.gen(function* () { | |
| 571 | const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer"); | |
| 572 | const response = yield* Effect.promise(() => | |
| 573 | fetch(bootstrapUrl, { | |
| 574 | method: "POST", | |
| 575 | headers: { | |
| 576 | "content-type": "application/json", | |
| 577 | }, | |
| 578 | body: JSON.stringify({ | |
| 579 | credential, | |
| 580 | }), | |
| 581 | }), | |
| 582 | ); | |
| 583 | const body = (yield* Effect.promise(() => response.json())) as { | |
| 584 | readonly authenticated: boolean; | |
| 585 | readonly sessionMethod: string; | |
| 586 | readonly expiresAt: string; | |
| 587 | readonly sessionToken?: string; | |
| 588 | readonly error?: string; | |
| 589 | }; | |
| 590 | return { | |
| 591 | response, | |
| 592 | body, | |
| 593 | }; | |
| 594 | }); | |
| 595 | ||
| 596 | const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => | |
| 597 | Effect.gen(function* () { | |
| 598 | const { response, cookie } = yield* bootstrapBrowserSession(credential); | |
| 599 | if (!response.ok) { | |
| 600 | return yield* Effect.fail( | |
| 601 | new Error(`Expected bootstrap session response to succeed, got ${response.status}`), | |
| 602 | ); | |
| 603 | } | |
| 604 | ||
| 605 | if (!cookie) { | |
| 606 | return yield* Effect.fail(new Error("Expected bootstrap session response to set a cookie.")); | |
| 607 | } | |
| 608 | ||
| 609 | return cookie.split(";")[0] ?? cookie; | |
| 610 | }); | |
| 611 | ||
| 612 | const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => | |
| 613 | Effect.gen(function* () { | |
| 614 | const { response, body } = yield* bootstrapBearerSession(credential); | |
| 615 | if (!response.ok) { | |
| 616 | return yield* Effect.fail( | |
| 617 | new Error(`Expected bearer bootstrap response to succeed, got ${response.status}`), | |
| 618 | ); | |
| 619 | } | |
| 620 | ||
| 621 | if (!body.sessionToken) { | |
| 622 | return yield* Effect.fail( | |
| 623 | new Error("Expected bearer bootstrap response to include a session token."), | |
| 624 | ); | |
| 625 | } | |
| 626 | ||
| 627 | return body.sessionToken; | |
| 628 | }); | |
| 629 | ||
| 630 | const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { | |
| 631 | const [nameValue] = cookieHeader.split(";", 1); | |
| 632 | const token = nameValue?.split("=", 2)[1]; | |
| 633 | if (!token) { | |
| 634 | throw new Error("Expected session cookie header to contain a token value."); | |
| 635 | } | |
| 636 | return token; | |
| 637 | }; | |
| 638 | ||
| 639 | const splitHeaderTokens = (value: string | null) => | |
| 640 | (value ?? "") | |
| 641 | .split(",") | |
| 642 | .map((token) => token.trim()) | |
| 643 | .filter((token) => token.length > 0) | |
| 644 | .toSorted(); | |
| 645 | ||
| 646 | const getWsServerUrl = ( | |
| 647 | pathname = "", | |
| 648 | options?: { authenticated?: boolean; credential?: string }, | |
| 649 | ) => | |
| 488 | 650 | Effect.gen(function* () { |
| 489 | 651 | const server = yield* HttpServer.HttpServer; |
| 490 | 652 | const address = server.address as HttpServer.TcpAddress; |
| 491 | return `ws://127.0.0.1:${address.port}${pathname}`; | |
| 653 | const baseUrl = `ws://127.0.0.1:${address.port}${pathname}`; | |
| 654 | if (options?.authenticated === false) { | |
| 655 | return baseUrl; | |
| 656 | } | |
| 657 | return appendSessionCookieToWsUrl( | |
| 658 | baseUrl, | |
| 659 | yield* getAuthenticatedSessionCookieHeader(options?.credential), | |
| 660 | ); | |
| 492 | 661 | }); |
| 493 | 662 | |
| 494 | 663 | it.layer(NodeServices.layer)("server router seam", (it) => { |
| ... | ... | @@ -514,11 +683,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 514 | 683 | config: { devUrl: new URL("http://127.0.0.1:5173") }, |
| 515 | 684 | }); |
| 516 | 685 | |
| 517 | const url = yield* getHttpServerUrl("/foo/bar"); | |
| 686 | const url = yield* getHttpServerUrl("/foo/bar?token=test-token"); | |
| 518 | 687 | const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" })); |
| 519 | 688 | |
| 520 | 689 | assert.equal(response.status, 302); |
| 521 | assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/"); | |
| 690 | assert.equal( | |
| 691 | response.headers.get("location"), | |
| 692 | "http://127.0.0.1:5173/foo/bar?token=test-token", | |
| 693 | ); | |
| 522 | 694 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 523 | 695 | ); |
| 524 | 696 | |
| ... | ... | @@ -540,6 +712,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 540 | 712 | |
| 541 | 713 | const response = yield* HttpClient.get( |
| 542 | 714 | `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, |
| 715 | { | |
| 716 | headers: { | |
| 717 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 718 | }, | |
| 719 | }, | |
| 543 | 720 | ); |
| 544 | 721 | |
| 545 | 722 | assert.equal(response.status, 200); |
| ... | ... | @@ -560,6 +737,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 560 | 737 | |
| 561 | 738 | const response = yield* HttpClient.get( |
| 562 | 739 | `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, |
| 740 | { | |
| 741 | headers: { | |
| 742 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 743 | }, | |
| 744 | }, | |
| 563 | 745 | ); |
| 564 | 746 | |
| 565 | 747 | assert.equal(response.status, 200); |
| ... | ... | @@ -567,6 +749,597 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 567 | 749 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 568 | 750 | ); |
| 569 | 751 | |
| 752 | it.effect("serves the public environment descriptor without requiring auth", () => | |
| 753 | Effect.gen(function* () { | |
| 754 | yield* buildAppUnderTest(); | |
| 755 | ||
| 756 | const url = yield* getHttpServerUrl("/.well-known/t3/environment"); | |
| 757 | const response = yield* Effect.promise(() => fetch(url)); | |
| 758 | const body = (yield* Effect.promise(() => | |
| 759 | response.json(), | |
| 760 | )) as typeof testEnvironmentDescriptor; | |
| 761 | ||
| 762 | assert.equal(response.status, 200); | |
| 763 | assert.deepEqual(body, testEnvironmentDescriptor); | |
| 764 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 765 | ); | |
| 766 | ||
| 767 | it.effect("reports unauthenticated session state without requiring auth", () => | |
| 768 | Effect.gen(function* () { | |
| 769 | yield* buildAppUnderTest(); | |
| 770 | ||
| 771 | const url = yield* getHttpServerUrl("/api/auth/session"); | |
| 772 | const response = yield* Effect.promise(() => fetch(url)); | |
| 773 | const body = (yield* Effect.promise(() => response.json())) as { | |
| 774 | readonly authenticated: boolean; | |
| 775 | readonly auth: { | |
| 776 | readonly policy: string; | |
| 777 | readonly bootstrapMethods: ReadonlyArray<string>; | |
| 778 | readonly sessionMethods: ReadonlyArray<string>; | |
| 779 | readonly sessionCookieName: string; | |
| 780 | }; | |
| 781 | }; | |
| 782 | ||
| 783 | assert.equal(response.status, 200); | |
| 784 | assert.equal(body.authenticated, false); | |
| 785 | assert.equal(body.auth.policy, "desktop-managed-local"); | |
| 786 | assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); | |
| 787 | assert.deepEqual(body.auth.sessionMethods, [ | |
| 788 | "browser-session-cookie", | |
| 789 | "bearer-session-token", | |
| 790 | ]); | |
| 791 | assert.equal(body.auth.sessionCookieName, "t3_session"); | |
| 792 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 793 | ); | |
| 794 | ||
| 795 | it.effect("bootstraps a browser session and authenticates the session endpoint via cookie", () => | |
| 796 | Effect.gen(function* () { | |
| 797 | yield* buildAppUnderTest(); | |
| 798 | ||
| 799 | const { | |
| 800 | response: bootstrapResponse, | |
| 801 | body: bootstrapBody, | |
| 802 | cookie: setCookie, | |
| 803 | } = yield* bootstrapBrowserSession(); | |
| 804 | ||
| 805 | assert.equal(bootstrapResponse.status, 200); | |
| 806 | assert.equal(bootstrapBody.authenticated, true); | |
| 807 | assert.equal(bootstrapBody.sessionMethod, "browser-session-cookie"); | |
| 808 | assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken); | |
| 809 | assert.isDefined(setCookie); | |
| 810 | ||
| 811 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); | |
| 812 | const sessionResponse = yield* Effect.promise(() => | |
| 813 | fetch(sessionUrl, { | |
| 814 | headers: { | |
| 815 | cookie: setCookie?.split(";")[0] ?? "", | |
| 816 | }, | |
| 817 | }), | |
| 818 | ); | |
| 819 | const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as { | |
| 820 | readonly authenticated: boolean; | |
| 821 | readonly sessionMethod?: string; | |
| 822 | }; | |
| 823 | ||
| 824 | assert.equal(sessionResponse.status, 200); | |
| 825 | assert.equal(sessionBody.authenticated, true); | |
| 826 | assert.equal(sessionBody.sessionMethod, "browser-session-cookie"); | |
| 827 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 828 | ); | |
| 829 | ||
| 830 | it.effect( | |
| 831 | "bootstraps a bearer session and authenticates the session endpoint via authorization header", | |
| 832 | () => | |
| 833 | Effect.gen(function* () { | |
| 834 | yield* buildAppUnderTest(); | |
| 835 | ||
| 836 | const { response: bootstrapResponse, body: bootstrapBody } = | |
| 837 | yield* bootstrapBearerSession(); | |
| 838 | ||
| 839 | assert.equal(bootstrapResponse.status, 200); | |
| 840 | assert.equal(bootstrapBody.authenticated, true); | |
| 841 | assert.equal(bootstrapBody.sessionMethod, "bearer-session-token"); | |
| 842 | assert.equal(typeof bootstrapBody.sessionToken, "string"); | |
| 843 | assert.isTrue((bootstrapBody.sessionToken?.length ?? 0) > 0); | |
| 844 | ||
| 845 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); | |
| 846 | const sessionResponse = yield* Effect.promise(() => | |
| 847 | fetch(sessionUrl, { | |
| 848 | headers: { | |
| 849 | authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, | |
| 850 | }, | |
| 851 | }), | |
| 852 | ); | |
| 853 | const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as { | |
| 854 | readonly authenticated: boolean; | |
| 855 | readonly sessionMethod?: string; | |
| 856 | }; | |
| 857 | ||
| 858 | assert.equal(sessionResponse.status, 200); | |
| 859 | assert.equal(sessionBody.authenticated, true); | |
| 860 | assert.equal(sessionBody.sessionMethod, "bearer-session-token"); | |
| 861 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 862 | ); | |
| 863 | ||
| 864 | it.effect("issues short-lived websocket tokens for authenticated bearer sessions", () => | |
| 865 | Effect.gen(function* () { | |
| 866 | yield* buildAppUnderTest(); | |
| 867 | ||
| 868 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | |
| 869 | const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); | |
| 870 | const wsTokenResponse = yield* Effect.promise(() => | |
| 871 | fetch(wsTokenUrl, { | |
| 872 | method: "POST", | |
| 873 | headers: { | |
| 874 | authorization: `Bearer ${bearerToken}`, | |
| 875 | }, | |
| 876 | }), | |
| 877 | ); | |
| 878 | const wsTokenBody = (yield* Effect.promise(() => wsTokenResponse.json())) as { | |
| 879 | readonly token: string; | |
| 880 | readonly expiresAt: string; | |
| 881 | }; | |
| 882 | ||
| 883 | assert.equal(wsTokenResponse.status, 200); | |
| 884 | assert.equal(typeof wsTokenBody.token, "string"); | |
| 885 | assert.isTrue(wsTokenBody.token.length > 0); | |
| 886 | assert.equal(typeof wsTokenBody.expiresAt, "string"); | |
| 887 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 888 | ); | |
| 889 | ||
| 890 | it.effect( | |
| 891 | "responds to remote auth websocket-token preflight requests with authorization CORS headers", | |
| 892 | () => | |
| 893 | Effect.gen(function* () { | |
| 894 | yield* buildAppUnderTest(); | |
| 895 | ||
| 896 | const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); | |
| 897 | const response = yield* Effect.promise(() => | |
| 898 | fetch(wsTokenUrl, { | |
| 899 | method: "OPTIONS", | |
| 900 | headers: { | |
| 901 | origin: "http://192.168.86.35:3773", | |
| 902 | "access-control-request-method": "POST", | |
| 903 | "access-control-request-headers": "authorization", | |
| 904 | }, | |
| 905 | }), | |
| 906 | ); | |
| 907 | ||
| 908 | assert.equal(response.status, 204); | |
| 909 | assert.equal(response.headers.get("access-control-allow-origin"), "*"); | |
| 910 | assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [ | |
| 911 | "GET", | |
| 912 | "OPTIONS", | |
| 913 | "POST", | |
| 914 | ]); | |
| 915 | assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [ | |
| 916 | "authorization", | |
| 917 | "b3", | |
| 918 | "content-type", | |
| 919 | "traceparent", | |
| 920 | ]); | |
| 921 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 922 | ); | |
| 923 | ||
| 924 | it.effect("includes CORS headers on remote websocket-token auth failures", () => | |
| 925 | Effect.gen(function* () { | |
| 926 | yield* buildAppUnderTest(); | |
| 927 | ||
| 928 | const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); | |
| 929 | const response = yield* Effect.promise(() => | |
| 930 | fetch(wsTokenUrl, { | |
| 931 | method: "POST", | |
| 932 | headers: { | |
| 933 | origin: "http://192.168.86.35:3773", | |
| 934 | }, | |
| 935 | }), | |
| 936 | ); | |
| 937 | const body = (yield* Effect.promise(() => response.json())) as { | |
| 938 | readonly error?: string; | |
| 939 | }; | |
| 940 | ||
| 941 | assert.equal(response.status, 401); | |
| 942 | assert.equal(response.headers.get("access-control-allow-origin"), "*"); | |
| 943 | assert.equal(body.error, "Authentication required."); | |
| 944 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 945 | ); | |
| 946 | ||
| 947 | it.effect("issues authenticated one-time pairing credentials for additional clients", () => | |
| 948 | Effect.gen(function* () { | |
| 949 | yield* buildAppUnderTest(); | |
| 950 | ||
| 951 | const response = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 952 | headers: { | |
| 953 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 954 | }, | |
| 955 | }); | |
| 956 | const body = (yield* response.json) as { | |
| 957 | readonly credential: string; | |
| 958 | readonly expiresAt: string; | |
| 959 | }; | |
| 960 | ||
| 961 | assert.equal(response.status, 200); | |
| 962 | assert.equal(typeof body.credential, "string"); | |
| 963 | assert.isTrue(body.credential.length > 0); | |
| 964 | assert.equal(typeof body.expiresAt, "string"); | |
| 965 | ||
| 966 | const bootstrapResult = yield* bootstrapBrowserSession(body.credential); | |
| 967 | assert.equal(bootstrapResult.response.status, 200); | |
| 968 | ||
| 969 | const reusedResult = yield* bootstrapBrowserSession(body.credential); | |
| 970 | assert.equal(reusedResult.response.status, 401); | |
| 971 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 972 | ); | |
| 973 | ||
| 974 | it.effect("rejects unauthenticated pairing credential requests", () => | |
| 975 | Effect.gen(function* () { | |
| 976 | yield* buildAppUnderTest(); | |
| 977 | ||
| 978 | const response = yield* HttpClient.post("/api/auth/pairing-token"); | |
| 979 | assert.equal(response.status, 401); | |
| 980 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 981 | ); | |
| 982 | ||
| 983 | it.effect("lists and revokes pairing links for owner sessions", () => | |
| 984 | Effect.gen(function* () { | |
| 985 | yield* buildAppUnderTest({ | |
| 986 | config: { | |
| 987 | host: "0.0.0.0", | |
| 988 | }, | |
| 989 | }); | |
| 990 | ||
| 991 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | |
| 992 | const createdResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 993 | headers: { | |
| 994 | cookie: ownerCookie, | |
| 995 | }, | |
| 996 | }); | |
| 997 | const createdBody = (yield* createdResponse.json) as { | |
| 998 | readonly id: string; | |
| 999 | readonly credential: string; | |
| 1000 | }; | |
| 1001 | ||
| 1002 | const listResponse = yield* HttpClient.get("/api/auth/pairing-links", { | |
| 1003 | headers: { | |
| 1004 | cookie: ownerCookie, | |
| 1005 | }, | |
| 1006 | }); | |
| 1007 | const listedLinks = (yield* listResponse.json) as ReadonlyArray<{ | |
| 1008 | readonly id: string; | |
| 1009 | readonly credential: string; | |
| 1010 | }>; | |
| 1011 | ||
| 1012 | const revokeUrl = yield* getHttpServerUrl("/api/auth/pairing-links/revoke"); | |
| 1013 | const revokeResponse = yield* Effect.promise(() => | |
| 1014 | fetch(revokeUrl, { | |
| 1015 | method: "POST", | |
| 1016 | headers: { | |
| 1017 | cookie: ownerCookie, | |
| 1018 | "content-type": "application/json", | |
| 1019 | }, | |
| 1020 | body: JSON.stringify({ id: createdBody.id }), | |
| 1021 | }), | |
| 1022 | ); | |
| 1023 | const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential); | |
| 1024 | ||
| 1025 | assert.equal(createdResponse.status, 200); | |
| 1026 | assert.equal(listResponse.status, 200); | |
| 1027 | assert.isTrue(listedLinks.some((entry) => entry.id === createdBody.id)); | |
| 1028 | assert.equal(revokeResponse.status, 200); | |
| 1029 | assert.equal(revokedBootstrap.response.status, 401); | |
| 1030 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1031 | ); | |
| 1032 | ||
| 1033 | it.effect("rejects pairing credential requests from non-owner paired sessions", () => | |
| 1034 | Effect.gen(function* () { | |
| 1035 | yield* buildAppUnderTest({ | |
| 1036 | config: { | |
| 1037 | host: "0.0.0.0", | |
| 1038 | }, | |
| 1039 | }); | |
| 1040 | ||
| 1041 | const ownerResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1042 | headers: { | |
| 1043 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 1044 | }, | |
| 1045 | }); | |
| 1046 | const ownerBody = (yield* ownerResponse.json) as { | |
| 1047 | readonly credential: string; | |
| 1048 | }; | |
| 1049 | assert.equal(ownerResponse.status, 200); | |
| 1050 | ||
| 1051 | const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader(ownerBody.credential); | |
| 1052 | const pairedResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1053 | headers: { | |
| 1054 | cookie: pairedSessionCookie, | |
| 1055 | }, | |
| 1056 | }); | |
| 1057 | const pairedBody = (yield* pairedResponse.json) as { | |
| 1058 | readonly error: string; | |
| 1059 | }; | |
| 1060 | ||
| 1061 | assert.equal(pairedResponse.status, 403); | |
| 1062 | assert.equal(pairedBody.error, "Only owner sessions can create pairing credentials."); | |
| 1063 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1064 | ); | |
| 1065 | ||
| 1066 | it.effect("lists paired clients and revokes other sessions while keeping the owner", () => | |
| 1067 | Effect.gen(function* () { | |
| 1068 | yield* buildAppUnderTest({ | |
| 1069 | config: { | |
| 1070 | host: "0.0.0.0", | |
| 1071 | }, | |
| 1072 | }); | |
| 1073 | ||
| 1074 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | |
| 1075 | const pairingTokenUrl = yield* getHttpServerUrl("/api/auth/pairing-token"); | |
| 1076 | const ownerPairingResponse = yield* Effect.promise(() => | |
| 1077 | fetch(pairingTokenUrl, { | |
| 1078 | method: "POST", | |
| 1079 | headers: { | |
| 1080 | cookie: ownerCookie, | |
| 1081 | "content-type": "application/json", | |
| 1082 | }, | |
| 1083 | body: JSON.stringify({ | |
| 1084 | label: "Julius iPhone", | |
| 1085 | }), | |
| 1086 | }), | |
| 1087 | ); | |
| 1088 | const ownerPairingBody = (yield* Effect.promise(() => ownerPairingResponse.json())) as { | |
| 1089 | readonly credential: string; | |
| 1090 | readonly label?: string; | |
| 1091 | }; | |
| 1092 | assert.equal(ownerPairingResponse.status, 200); | |
| 1093 | const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, { | |
| 1094 | headers: { | |
| 1095 | "user-agent": | |
| 1096 | "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", | |
| 1097 | }, | |
| 1098 | }); | |
| 1099 | const pairedSessionCookie = pairedSessionBootstrap.cookie?.split(";")[0]; | |
| 1100 | assert.isDefined(pairedSessionCookie); | |
| 1101 | ||
| 1102 | const pairedSessionCookieHeader = pairedSessionCookie ?? ""; | |
| 1103 | const listClientsUrl = yield* getHttpServerUrl("/api/auth/clients"); | |
| 1104 | const listBeforeResponse = yield* Effect.promise(() => | |
| 1105 | fetch(listClientsUrl, { | |
| 1106 | headers: { | |
| 1107 | cookie: ownerCookie, | |
| 1108 | }, | |
| 1109 | }), | |
| 1110 | ); | |
| 1111 | const clientsBefore = (yield* Effect.promise(() => | |
| 1112 | listBeforeResponse.json(), | |
| 1113 | )) as ReadonlyArray<{ | |
| 1114 | readonly sessionId: string; | |
| 1115 | readonly current: boolean; | |
| 1116 | readonly client: { | |
| 1117 | readonly label?: string; | |
| 1118 | readonly deviceType: string; | |
| 1119 | readonly ipAddress?: string; | |
| 1120 | readonly os?: string; | |
| 1121 | readonly browser?: string; | |
| 1122 | }; | |
| 1123 | }>; | |
| 1124 | const pairedClientBefore = clientsBefore.find((entry) => !entry.current); | |
| 1125 | const pairedSessionId = clientsBefore.find((entry) => !entry.current)?.sessionId; | |
| 1126 | ||
| 1127 | const revokeOthersResponse = yield* HttpClient.post("/api/auth/clients/revoke-others", { | |
| 1128 | headers: { | |
| 1129 | cookie: ownerCookie, | |
| 1130 | }, | |
| 1131 | }); | |
| 1132 | const revokeOthersBody = (yield* revokeOthersResponse.json) as { | |
| 1133 | readonly revokedCount: number; | |
| 1134 | }; | |
| 1135 | ||
| 1136 | const listAfterResponse = yield* HttpClient.get("/api/auth/clients", { | |
| 1137 | headers: { | |
| 1138 | cookie: ownerCookie, | |
| 1139 | }, | |
| 1140 | }); | |
| 1141 | const clientsAfter = (yield* listAfterResponse.json) as ReadonlyArray<{ | |
| 1142 | readonly sessionId: string; | |
| 1143 | readonly current: boolean; | |
| 1144 | }>; | |
| 1145 | ||
| 1146 | const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1147 | headers: { | |
| 1148 | cookie: pairedSessionCookieHeader, | |
| 1149 | }, | |
| 1150 | }); | |
| 1151 | const pairedClientPairingBody = (yield* pairedClientPairingResponse.json) as { | |
| 1152 | readonly error: string; | |
| 1153 | }; | |
| 1154 | ||
| 1155 | assert.equal(listBeforeResponse.status, 200); | |
| 1156 | assert.equal(ownerPairingBody.label, "Julius iPhone"); | |
| 1157 | assert.lengthOf(clientsBefore, 2); | |
| 1158 | assert.isDefined(pairedSessionId); | |
| 1159 | assert.isDefined(pairedClientBefore); | |
| 1160 | assert.deepInclude(pairedClientBefore?.client, { | |
| 1161 | label: "Julius iPhone", | |
| 1162 | deviceType: "mobile", | |
| 1163 | os: "iOS", | |
| 1164 | browser: "Safari", | |
| 1165 | ipAddress: "127.0.0.1", | |
| 1166 | }); | |
| 1167 | assert.equal(revokeOthersResponse.status, 200); | |
| 1168 | assert.equal(revokeOthersBody.revokedCount, 1); | |
| 1169 | assert.equal(listAfterResponse.status, 200); | |
| 1170 | assert.lengthOf(clientsAfter, 1); | |
| 1171 | assert.equal(clientsAfter[0]?.current, true); | |
| 1172 | assert.equal(pairedClientPairingResponse.status, 401); | |
| 1173 | assert.equal(pairedClientPairingBody.error, "Unauthorized request."); | |
| 1174 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1175 | ); | |
| 1176 | ||
| 1177 | it.effect("revokes an individual paired client session", () => | |
| 1178 | Effect.gen(function* () { | |
| 1179 | yield* buildAppUnderTest({ | |
| 1180 | config: { | |
| 1181 | host: "0.0.0.0", | |
| 1182 | }, | |
| 1183 | }); | |
| 1184 | ||
| 1185 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | |
| 1186 | const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1187 | headers: { | |
| 1188 | cookie: ownerCookie, | |
| 1189 | }, | |
| 1190 | }); | |
| 1191 | const pairingBody = (yield* pairingResponse.json) as { | |
| 1192 | readonly credential: string; | |
| 1193 | }; | |
| 1194 | const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader( | |
| 1195 | pairingBody.credential, | |
| 1196 | ); | |
| 1197 | ||
| 1198 | const clientsResponse = yield* HttpClient.get("/api/auth/clients", { | |
| 1199 | headers: { | |
| 1200 | cookie: ownerCookie, | |
| 1201 | }, | |
| 1202 | }); | |
| 1203 | const clients = (yield* clientsResponse.json) as ReadonlyArray<{ | |
| 1204 | readonly sessionId: string; | |
| 1205 | readonly current: boolean; | |
| 1206 | }>; | |
| 1207 | const pairedSessionId = clients.find((entry) => !entry.current)?.sessionId; | |
| 1208 | assert.isDefined(pairedSessionId); | |
| 1209 | ||
| 1210 | const revokeUrl = yield* getHttpServerUrl("/api/auth/clients/revoke"); | |
| 1211 | const revokeResponse = yield* Effect.promise(() => | |
| 1212 | fetch(revokeUrl, { | |
| 1213 | method: "POST", | |
| 1214 | headers: { | |
| 1215 | cookie: ownerCookie, | |
| 1216 | "content-type": "application/json", | |
| 1217 | }, | |
| 1218 | body: JSON.stringify({ sessionId: pairedSessionId }), | |
| 1219 | }), | |
| 1220 | ); | |
| 1221 | const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1222 | headers: { | |
| 1223 | cookie: pairedSessionCookie, | |
| 1224 | }, | |
| 1225 | }); | |
| 1226 | ||
| 1227 | assert.equal(revokeResponse.status, 200); | |
| 1228 | assert.equal(pairedClientPairingResponse.status, 401); | |
| 1229 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1230 | ); | |
| 1231 | ||
| 1232 | it.effect("rejects reusing the same bootstrap credential after it has been exchanged", () => | |
| 1233 | Effect.gen(function* () { | |
| 1234 | yield* buildAppUnderTest(); | |
| 1235 | ||
| 1236 | const first = yield* bootstrapBrowserSession(); | |
| 1237 | const second = yield* bootstrapBrowserSession(); | |
| 1238 | ||
| 1239 | assert.equal(first.response.status, 200); | |
| 1240 | assert.equal(second.response.status, 401); | |
| 1241 | assert.equal( | |
| 1242 | (second.body as { readonly error?: string }).error, | |
| 1243 | "Invalid bootstrap credential.", | |
| 1244 | ); | |
| 1245 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1246 | ); | |
| 1247 | ||
| 1248 | it.effect( | |
| 1249 | "does not accept session tokens via query parameters on authenticated HTTP routes", | |
| 1250 | () => | |
| 1251 | Effect.gen(function* () { | |
| 1252 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1253 | const projectDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1254 | prefix: "t3-router-project-favicon-query-token-", | |
| 1255 | }); | |
| 1256 | ||
| 1257 | yield* buildAppUnderTest(); | |
| 1258 | ||
| 1259 | const { cookie } = yield* bootstrapBrowserSession(); | |
| 1260 | assert.isDefined(cookie); | |
| 1261 | const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); | |
| 1262 | ||
| 1263 | const response = yield* HttpClient.get( | |
| 1264 | `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`, | |
| 1265 | ); | |
| 1266 | ||
| 1267 | assert.equal(response.status, 401); | |
| 1268 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1269 | ); | |
| 1270 | ||
| 1271 | it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => | |
| 1272 | Effect.gen(function* () { | |
| 1273 | yield* buildAppUnderTest(); | |
| 1274 | ||
| 1275 | const { response: bootstrapResponse, cookie } = yield* bootstrapBrowserSession(); | |
| 1276 | ||
| 1277 | assert.equal(bootstrapResponse.status, 200); | |
| 1278 | assert.isDefined(cookie); | |
| 1279 | ||
| 1280 | const wsUrl = appendSessionCookieToWsUrl( | |
| 1281 | yield* getWsServerUrl("/ws", { authenticated: false }), | |
| 1282 | cookie?.split(";")[0] ?? "", | |
| 1283 | ); | |
| 1284 | const response = yield* Effect.scoped( | |
| 1285 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), | |
| 1286 | ); | |
| 1287 | ||
| 1288 | assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); | |
| 1289 | assert.equal(response.auth.policy, "desktop-managed-local"); | |
| 1290 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1291 | ); | |
| 1292 | ||
| 1293 | it.effect( | |
| 1294 | "rejects websocket rpc handshake when a session token is only provided via query string", | |
| 1295 | () => | |
| 1296 | Effect.gen(function* () { | |
| 1297 | yield* buildAppUnderTest(); | |
| 1298 | ||
| 1299 | const { cookie } = yield* bootstrapBrowserSession(); | |
| 1300 | assert.isDefined(cookie); | |
| 1301 | const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); | |
| 1302 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?token=${encodeURIComponent(sessionToken)}`; | |
| 1303 | ||
| 1304 | const error = yield* Effect.flip( | |
| 1305 | Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), | |
| 1306 | ); | |
| 1307 | ||
| 1308 | assert.equal(error._tag, "RpcClientError"); | |
| 1309 | assertInclude(String(error), "401"); | |
| 1310 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1311 | ); | |
| 1312 | ||
| 1313 | it.effect( | |
| 1314 | "accepts websocket rpc handshake with a dedicated websocket token in the query string", | |
| 1315 | () => | |
| 1316 | Effect.gen(function* () { | |
| 1317 | yield* buildAppUnderTest(); | |
| 1318 | ||
| 1319 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | |
| 1320 | const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); | |
| 1321 | const wsTokenResponse = yield* Effect.promise(() => | |
| 1322 | fetch(wsTokenUrl, { | |
| 1323 | method: "POST", | |
| 1324 | headers: { | |
| 1325 | authorization: `Bearer ${bearerToken}`, | |
| 1326 | }, | |
| 1327 | }), | |
| 1328 | ); | |
| 1329 | const wsTokenBody = (yield* Effect.promise(() => wsTokenResponse.json())) as { | |
| 1330 | readonly token: string; | |
| 1331 | }; | |
| 1332 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsToken=${encodeURIComponent(wsTokenBody.token)}`; | |
| 1333 | ||
| 1334 | const response = yield* Effect.scoped( | |
| 1335 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), | |
| 1336 | ); | |
| 1337 | ||
| 1338 | assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); | |
| 1339 | assert.equal(response.auth.policy, "desktop-managed-local"); | |
| 1340 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1341 | ); | |
| 1342 | ||
| 570 | 1343 | it.effect("serves attachment files from state dir", () => |
| 571 | 1344 | Effect.gen(function* () { |
| 572 | 1345 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -583,7 +1356,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 583 | 1356 | yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); |
| 584 | 1357 | yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); |
| 585 | 1358 | |
| 586 | const response = yield* HttpClient.get(`/attachments/${attachmentId}`); | |
| 1359 | const response = yield* HttpClient.get(`/attachments/${attachmentId}`, { | |
| 1360 | headers: { | |
| 1361 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 1362 | }, | |
| 1363 | }); | |
| 587 | 1364 | assert.equal(response.status, 200); |
| 588 | 1365 | assert.equal(yield* response.text, "attachment-ok"); |
| 589 | 1366 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| ... | ... | @@ -606,6 +1383,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 606 | 1383 | |
| 607 | 1384 | const response = yield* HttpClient.get( |
| 608 | 1385 | "/attachments/thread%20folder/message%20folder/file%20name.png", |
| 1386 | { | |
| 1387 | headers: { | |
| 1388 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 1389 | }, | |
| 1390 | }, | |
| 609 | 1391 | ); |
| 610 | 1392 | assert.equal(response.status, 200); |
| 611 | 1393 | assert.equal(yield* response.text, "attachment-encoded-ok"); |
| ... | ... | @@ -742,6 +1524,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 742 | 1524 | |
| 743 | 1525 | const response = yield* HttpClient.post("/api/observability/v1/traces", { |
| 744 | 1526 | headers: { |
| 1527 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 745 | 1528 | "content-type": "application/json", |
| 746 | 1529 | origin: "http://localhost:5733", |
| 747 | 1530 | }, |
| ... | ... | @@ -815,8 +1598,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 815 | 1598 | |
| 816 | 1599 | assert.equal(response.status, 204); |
| 817 | 1600 | assert.equal(response.headers.get("access-control-allow-origin"), "*"); |
| 818 | assert.equal(response.headers.get("access-control-allow-methods"), "POST, OPTIONS"); | |
| 819 | assert.equal(response.headers.get("access-control-allow-headers"), "content-type"); | |
| 1601 | assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [ | |
| 1602 | "GET", | |
| 1603 | "OPTIONS", | |
| 1604 | "POST", | |
| 1605 | ]); | |
| 1606 | assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [ | |
| 1607 | "authorization", | |
| 1608 | "b3", | |
| 1609 | "content-type", | |
| 1610 | "traceparent", | |
| 1611 | ]); | |
| 820 | 1612 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 821 | 1613 | ); |
| 822 | 1614 | |
| ... | ... | @@ -850,6 +1642,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 850 | 1642 | |
| 851 | 1643 | const response = yield* HttpClient.post("/api/observability/v1/traces", { |
| 852 | 1644 | headers: { |
| 1645 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 853 | 1646 | "content-type": "application/json", |
| 854 | 1647 | }, |
| 855 | 1648 | body: HttpBody.text(JSON.stringify(payload), "application/json"), |
| ... | ... | @@ -897,6 +1690,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 897 | 1690 | |
| 898 | 1691 | const response = yield* HttpClient.get( |
| 899 | 1692 | "/attachments/missing-11111111-1111-4111-8111-111111111111", |
| 1693 | { | |
| 1694 | headers: { | |
| 1695 | cookie: yield* getAuthenticatedSessionCookieHeader(), | |
| 1696 | }, | |
| 1697 | }, | |
| 900 | 1698 | ); |
| 901 | 1699 | assert.equal(response.status, 404); |
| 902 | 1700 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| ... | ... | @@ -938,7 +1736,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 938 | 1736 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 939 | 1737 | ); |
| 940 | 1738 | |
| 941 | it.effect("rejects websocket rpc handshake when auth token is missing", () => | |
| 1739 | it.effect("rejects websocket rpc handshake when session authentication is missing", () => | |
| 942 | 1740 | Effect.gen(function* () { |
| 943 | 1741 | const fs = yield* FileSystem.FileSystem; |
| 944 | 1742 | const path = yield* Path.Path; |
| ... | ... | @@ -948,13 +1746,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 948 | 1746 | "export const needle = 1;", |
| 949 | 1747 | ); |
| 950 | 1748 | |
| 951 | yield* buildAppUnderTest({ | |
| 952 | config: { | |
| 953 | authToken: "secret-token", | |
| 954 | }, | |
| 955 | }); | |
| 1749 | yield* buildAppUnderTest(); | |
| 956 | 1750 | |
| 957 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 1751 | const wsUrl = yield* getWsServerUrl("/ws", { authenticated: false }); | |
| 958 | 1752 | const result = yield* Effect.scoped( |
| 959 | 1753 | withWsRpcClient(wsUrl, (client) => |
| 960 | 1754 | client[WS_METHODS.projectsSearchEntries]({ |
| ... | ... | @@ -970,38 +1764,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 970 | 1764 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 971 | 1765 | ); |
| 972 | 1766 | |
| 973 | it.effect("accepts websocket rpc handshake when auth token is provided", () => | |
| 974 | Effect.gen(function* () { | |
| 975 | const fs = yield* FileSystem.FileSystem; | |
| 976 | const path = yield* Path.Path; | |
| 977 | const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" }); | |
| 978 | yield* fs.writeFileString( | |
| 979 | path.join(workspaceDir, "needle-file.ts"), | |
| 980 | "export const needle = 1;", | |
| 981 | ); | |
| 982 | ||
| 983 | yield* buildAppUnderTest({ | |
| 984 | config: { | |
| 985 | authToken: "secret-token", | |
| 986 | }, | |
| 987 | }); | |
| 988 | ||
| 989 | const wsUrl = yield* getWsServerUrl("/ws?token=secret-token"); | |
| 990 | const response = yield* Effect.scoped( | |
| 991 | withWsRpcClient(wsUrl, (client) => | |
| 992 | client[WS_METHODS.projectsSearchEntries]({ | |
| 993 | cwd: workspaceDir, | |
| 994 | query: "needle", | |
| 995 | limit: 10, | |
| 996 | }), | |
| 997 | ), | |
| 998 | ); | |
| 999 | ||
| 1000 | assert.isAtLeast(response.entries.length, 1); | |
| 1001 | assert.equal(response.truncated, false); | |
| 1002 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1003 | ); | |
| 1004 | ||
| 1005 | 1767 | it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => |
| 1006 | 1768 | Effect.gen(function* () { |
| 1007 | 1769 | const providers = [] as const; |
apps/server/src/server.ts+34-1| ... | ... | @@ -6,7 +6,9 @@ import { |
| 6 | 6 | attachmentsRouteLayer, |
| 7 | 7 | otlpTracesProxyRouteLayer, |
| 8 | 8 | projectFaviconRouteLayer, |
| 9 | serverEnvironmentRouteLayer, | |
| 9 | 10 | staticAndDevRouteLayer, |
| 11 | browserApiCorsLayer, | |
| 10 | 12 | } from "./http"; |
| 11 | 13 | import { fixPath } from "./os-jank"; |
| 12 | 14 | import { websocketRpcRouteLayer } from "./ws"; |
| ... | ... | @@ -51,6 +53,20 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; |
| 51 | 53 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; |
| 52 | 54 | import { ObservabilityLive } from "./observability/Layers/Observability"; |
| 53 | 55 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; |
| 56 | import { | |
| 57 | authBearerBootstrapRouteLayer, | |
| 58 | authBootstrapRouteLayer, | |
| 59 | authClientsRevokeOthersRouteLayer, | |
| 60 | authClientsRevokeRouteLayer, | |
| 61 | authClientsRouteLayer, | |
| 62 | authPairingLinksRevokeRouteLayer, | |
| 63 | authPairingLinksRouteLayer, | |
| 64 | authPairingCredentialRouteLayer, | |
| 65 | authSessionRouteLayer, | |
| 66 | authWebSocketTokenRouteLayer, | |
| 67 | } from "./auth/http"; | |
| 68 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; | |
| 69 | import { ServerAuthLive } from "./auth/Layers/ServerAuth"; | |
| 54 | 70 | |
| 55 | 71 | const PtyAdapterLive = Layer.unwrap( |
| 56 | 72 | Effect.gen(function* () { |
| ... | ... | @@ -188,6 +204,11 @@ const WorkspaceLayerLive = Layer.mergeAll( |
| 188 | 204 | ), |
| 189 | 205 | ); |
| 190 | 206 | |
| 207 | const AuthLayerLive = ServerAuthLive.pipe( | |
| 208 | Layer.provideMerge(PersistenceLayerLive), | |
| 209 | Layer.provide(ServerSecretStoreLive), | |
| 210 | ); | |
| 211 | ||
| 191 | 212 | const RuntimeDependenciesLive = ReactorLayerLive.pipe( |
| 192 | 213 | // Core Services |
| 193 | 214 | Layer.provideMerge(CheckpointingLayerLive), |
| ... | ... | @@ -203,6 +224,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( |
| 203 | 224 | Layer.provideMerge(ProjectFaviconResolverLive), |
| 204 | 225 | Layer.provideMerge(RepositoryIdentityResolverLive), |
| 205 | 226 | Layer.provideMerge(ServerEnvironmentLive), |
| 227 | Layer.provideMerge(AuthLayerLive), | |
| 206 | 228 | |
| 207 | 229 | // Misc. |
| 208 | 230 | Layer.provideMerge(AnalyticsServiceLayerLive), |
| ... | ... | @@ -215,12 +237,23 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( |
| 215 | 237 | ); |
| 216 | 238 | |
| 217 | 239 | export const makeRoutesLayer = Layer.mergeAll( |
| 240 | authBearerBootstrapRouteLayer, | |
| 241 | authBootstrapRouteLayer, | |
| 242 | authClientsRevokeOthersRouteLayer, | |
| 243 | authClientsRevokeRouteLayer, | |
| 244 | authClientsRouteLayer, | |
| 245 | authPairingLinksRevokeRouteLayer, | |
| 246 | authPairingLinksRouteLayer, | |
| 247 | authPairingCredentialRouteLayer, | |
| 248 | authSessionRouteLayer, | |
| 249 | authWebSocketTokenRouteLayer, | |
| 218 | 250 | attachmentsRouteLayer, |
| 219 | 251 | otlpTracesProxyRouteLayer, |
| 220 | 252 | projectFaviconRouteLayer, |
| 253 | serverEnvironmentRouteLayer, | |
| 221 | 254 | staticAndDevRouteLayer, |
| 222 | 255 | websocketRpcRouteLayer, |
| 223 | ); | |
| 256 | ).pipe(Layer.provide(browserApiCorsLayer)); | |
| 224 | 257 | |
| 225 | 258 | export const makeServerLayer = Layer.unwrap( |
| 226 | 259 | Effect.gen(function* () { |
apps/server/src/serverRuntimeStartup.test.ts+9| ... | ... | @@ -1,14 +1,23 @@ |
| 1 | import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; | |
| 1 | 2 | import { assert, it } from "@effect/vitest"; |
| 2 | 3 | import { Deferred, Effect, Fiber, Option, Ref } from "effect"; |
| 3 | 4 | |
| 4 | 5 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; |
| 5 | 6 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 6 | 7 | import { |
| 8 | getAutoBootstrapDefaultModelSelection, | |
| 7 | 9 | launchStartupHeartbeat, |
| 8 | 10 | makeCommandGate, |
| 9 | 11 | ServerRuntimeStartupError, |
| 10 | 12 | } from "./serverRuntimeStartup.ts"; |
| 11 | 13 | |
| 14 | it("uses the canonical Codex default for auto-bootstrapped model selection", () => { | |
| 15 | assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { | |
| 16 | provider: "codex", | |
| 17 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | |
| 18 | }); | |
| 19 | }); | |
| 20 | ||
| 12 | 21 | it.effect("enqueueCommand waits for readiness and then drains queued work", () => |
| 13 | 22 | Effect.scoped( |
| 14 | 23 | Effect.gen(function* () { |
apps/server/src/serverRuntimeStartup.ts+40-21| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | CommandId, |
| 3 | DEFAULT_MODEL_BY_PROVIDER, | |
| 3 | 4 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 4 | 5 | type ModelSelection, |
| 5 | 6 | ProjectId, |
| ... | ... | @@ -29,6 +30,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents"; |
| 29 | 30 | import { ServerSettingsService } from "./serverSettings"; |
| 30 | 31 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; |
| 31 | 32 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 33 | import { ServerAuth } from "./auth/Services/ServerAuth"; | |
| 32 | 34 | |
| 33 | 35 | const isWildcardHost = (host: string | undefined): boolean => |
| 34 | 36 | host === "0.0.0.0" || host === "::" || host === "[::]"; |
| ... | ... | @@ -149,6 +151,11 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( |
| 149 | 151 | Effect.asVoid, |
| 150 | 152 | ); |
| 151 | 153 | |
| 154 | export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ | |
| 155 | provider: "codex", | |
| 156 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | |
| 157 | }); | |
| 158 | ||
| 152 | 159 | const autoBootstrapWelcome = Effect.gen(function* () { |
| 153 | 160 | const serverConfig = yield* ServerConfig; |
| 154 | 161 | const projectionReadModelQuery = yield* ProjectionSnapshotQuery; |
| ... | ... | @@ -170,10 +177,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { |
| 170 | 177 | const createdAt = new Date().toISOString(); |
| 171 | 178 | nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); |
| 172 | 179 | const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; |
| 173 | nextProjectDefaultModelSelection = { | |
| 174 | provider: "codex", | |
| 175 | model: "gpt-5-codex", | |
| 176 | }; | |
| 180 | nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection(); | |
| 177 | 181 | yield* orchestrationEngine.dispatch({ |
| 178 | 182 | type: "project.create", |
| 179 | 183 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), |
| ... | ... | @@ -185,10 +189,8 @@ const autoBootstrapWelcome = Effect.gen(function* () { |
| 185 | 189 | }); |
| 186 | 190 | } else { |
| 187 | 191 | nextProjectId = existingProject.value.id; |
| 188 | nextProjectDefaultModelSelection = existingProject.value.defaultModelSelection ?? { | |
| 189 | provider: "codex", | |
| 190 | model: "gpt-5-codex", | |
| 191 | }; | |
| 192 | nextProjectDefaultModelSelection = | |
| 193 | existingProject.value.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection(); | |
| 192 | 194 | } |
| 193 | 195 | |
| 194 | 196 | const existingThreadId = |
| ... | ... | @@ -229,28 +231,39 @@ const autoBootstrapWelcome = Effect.gen(function* () { |
| 229 | 231 | } as const; |
| 230 | 232 | }); |
| 231 | 233 | |
| 232 | const maybeOpenBrowser = Effect.gen(function* () { | |
| 234 | const resolveStartupBrowserTarget = Effect.gen(function* () { | |
| 233 | 235 | const serverConfig = yield* ServerConfig; |
| 234 | if (serverConfig.noBrowser) { | |
| 235 | return; | |
| 236 | } | |
| 237 | const { openBrowser } = yield* Open; | |
| 236 | const serverAuth = yield* ServerAuth; | |
| 238 | 237 | const localUrl = `http://localhost:${serverConfig.port}`; |
| 239 | 238 | const bindUrl = |
| 240 | 239 | serverConfig.host && !isWildcardHost(serverConfig.host) |
| 241 | 240 | ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` |
| 242 | 241 | : localUrl; |
| 243 | const target = serverConfig.devUrl?.toString() ?? bindUrl; | |
| 244 | ||
| 245 | yield* openBrowser(target).pipe( | |
| 246 | Effect.catch(() => | |
| 247 | Effect.logInfo("browser auto-open unavailable", { | |
| 248 | hint: `Open ${target} in your browser.`, | |
| 249 | }), | |
| 242 | const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; | |
| 243 | return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( | |
| 244 | Effect.flatMap((target) => | |
| 245 | target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), | |
| 250 | 246 | ), |
| 251 | 247 | ); |
| 252 | 248 | }); |
| 253 | 249 | |
| 250 | const maybeOpenBrowser = (target: string) => | |
| 251 | Effect.gen(function* () { | |
| 252 | const serverConfig = yield* ServerConfig; | |
| 253 | if (serverConfig.noBrowser) { | |
| 254 | return; | |
| 255 | } | |
| 256 | const { openBrowser } = yield* Open; | |
| 257 | ||
| 258 | yield* openBrowser(target).pipe( | |
| 259 | Effect.catch(() => | |
| 260 | Effect.logInfo("browser auto-open unavailable", { | |
| 261 | hint: `Open ${target} in your browser.`, | |
| 262 | }), | |
| 263 | ), | |
| 264 | ); | |
| 265 | }); | |
| 266 | ||
| 254 | 267 | const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>) => |
| 255 | 268 | effect.pipe( |
| 256 | 269 | Effect.annotateSpans({ "startup.phase": phase }), |
| ... | ... | @@ -371,7 +384,13 @@ const makeServerRuntimeStartup = Effect.gen(function* () { |
| 371 | 384 | yield* Effect.logDebug("startup phase: recording startup heartbeat"); |
| 372 | 385 | yield* launchStartupHeartbeat; |
| 373 | 386 | yield* Effect.logDebug("startup phase: browser open check"); |
| 374 | yield* runStartupPhase("browser.open", maybeOpenBrowser); | |
| 387 | const startupBrowserTarget = yield* resolveStartupBrowserTarget; | |
| 388 | if (serverConfig.mode !== "desktop") { | |
| 389 | yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe( | |
| 390 | Effect.annotateLogs({ pairingUrl: startupBrowserTarget }), | |
| 391 | ); | |
| 392 | } | |
| 393 | yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); | |
| 375 | 394 | yield* Effect.logDebug("startup phase: complete"); |
| 376 | 395 | }), |
| 377 | 396 | ); |
apps/server/src/ws.ts+833-728| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; | |
| 1 | import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"; | |
| 2 | 2 | import { |
| 3 | type AuthAccessStreamEvent, | |
| 4 | AuthSessionId, | |
| 3 | 5 | CommandId, |
| 4 | 6 | EventId, |
| 5 | 7 | type OrchestrationCommand, |
| ... | ... | @@ -20,7 +22,7 @@ import { |
| 20 | 22 | WsRpcGroup, |
| 21 | 23 | } from "@t3tools/contracts"; |
| 22 | 24 | import { clamp } from "effect/Number"; |
| 23 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 25 | import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; | |
| 24 | 26 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 25 | 27 | |
| 26 | 28 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; |
| ... | ... | @@ -49,791 +51,894 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat |
| 49 | 51 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; |
| 50 | 52 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; |
| 51 | 53 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; |
| 54 | import { ServerAuth } from "./auth/Services/ServerAuth"; | |
| 55 | import { | |
| 56 | BootstrapCredentialService, | |
| 57 | type BootstrapCredentialChange, | |
| 58 | } from "./auth/Services/BootstrapCredentialService"; | |
| 59 | import { | |
| 60 | SessionCredentialService, | |
| 61 | type SessionCredentialChange, | |
| 62 | } from "./auth/Services/SessionCredentialService"; | |
| 63 | import { respondToAuthError } from "./auth/http"; | |
| 52 | 64 | |
| 53 | const WsRpcLayer = WsRpcGroup.toLayer( | |
| 54 | Effect.gen(function* () { | |
| 55 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | |
| 56 | const orchestrationEngine = yield* OrchestrationEngineService; | |
| 57 | const checkpointDiffQuery = yield* CheckpointDiffQuery; | |
| 58 | const keybindings = yield* Keybindings; | |
| 59 | const open = yield* Open; | |
| 60 | const gitManager = yield* GitManager; | |
| 61 | const git = yield* GitCore; | |
| 62 | const gitStatusBroadcaster = yield* GitStatusBroadcaster; | |
| 63 | const terminalManager = yield* TerminalManager; | |
| 64 | const providerRegistry = yield* ProviderRegistry; | |
| 65 | const config = yield* ServerConfig; | |
| 66 | const lifecycleEvents = yield* ServerLifecycleEvents; | |
| 67 | const serverSettings = yield* ServerSettingsService; | |
| 68 | const startup = yield* ServerRuntimeStartup; | |
| 69 | const workspaceEntries = yield* WorkspaceEntries; | |
| 70 | const workspaceFileSystem = yield* WorkspaceFileSystem; | |
| 71 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; | |
| 72 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | |
| 73 | const serverEnvironment = yield* ServerEnvironment; | |
| 74 | const serverCommandId = (tag: string) => | |
| 75 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); | |
| 76 | ||
| 77 | const appendSetupScriptActivity = (input: { | |
| 78 | readonly threadId: ThreadId; | |
| 79 | readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; | |
| 80 | readonly summary: string; | |
| 81 | readonly createdAt: string; | |
| 82 | readonly payload: Record<string, unknown>; | |
| 83 | readonly tone: "info" | "error"; | |
| 84 | }) => | |
| 85 | orchestrationEngine.dispatch({ | |
| 86 | type: "thread.activity.append", | |
| 87 | commandId: serverCommandId("setup-script-activity"), | |
| 88 | threadId: input.threadId, | |
| 89 | activity: { | |
| 90 | id: EventId.makeUnsafe(crypto.randomUUID()), | |
| 91 | tone: input.tone, | |
| 92 | kind: input.kind, | |
| 93 | summary: input.summary, | |
| 94 | payload: input.payload, | |
| 95 | turnId: null, | |
| 96 | createdAt: input.createdAt, | |
| 65 | function toAuthAccessStreamEvent( | |
| 66 | change: BootstrapCredentialChange | SessionCredentialChange, | |
| 67 | revision: number, | |
| 68 | currentSessionId: AuthSessionId, | |
| 69 | ): AuthAccessStreamEvent { | |
| 70 | switch (change.type) { | |
| 71 | case "pairingLinkUpserted": | |
| 72 | return { | |
| 73 | version: 1, | |
| 74 | revision, | |
| 75 | type: "pairingLinkUpserted", | |
| 76 | payload: change.pairingLink, | |
| 77 | }; | |
| 78 | case "pairingLinkRemoved": | |
| 79 | return { | |
| 80 | version: 1, | |
| 81 | revision, | |
| 82 | type: "pairingLinkRemoved", | |
| 83 | payload: { id: change.id }, | |
| 84 | }; | |
| 85 | case "clientUpserted": | |
| 86 | return { | |
| 87 | version: 1, | |
| 88 | revision, | |
| 89 | type: "clientUpserted", | |
| 90 | payload: { | |
| 91 | ...change.clientSession, | |
| 92 | current: change.clientSession.sessionId === currentSessionId, | |
| 97 | 93 | }, |
| 98 | createdAt: input.createdAt, | |
| 99 | }); | |
| 94 | }; | |
| 95 | case "clientRemoved": | |
| 96 | return { | |
| 97 | version: 1, | |
| 98 | revision, | |
| 99 | type: "clientRemoved", | |
| 100 | payload: { sessionId: change.sessionId }, | |
| 101 | }; | |
| 102 | } | |
| 103 | } | |
| 100 | 104 | |
| 101 | const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => | |
| 102 | Schema.is(OrchestrationDispatchCommandError)(cause) | |
| 103 | ? cause | |
| 104 | : new OrchestrationDispatchCommandError({ | |
| 105 | message: cause instanceof Error ? cause.message : fallbackMessage, | |
| 106 | cause, | |
| 107 | }); | |
| 105 | const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | |
| 106 | WsRpcGroup.toLayer( | |
| 107 | Effect.gen(function* () { | |
| 108 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | |
| 109 | const orchestrationEngine = yield* OrchestrationEngineService; | |
| 110 | const checkpointDiffQuery = yield* CheckpointDiffQuery; | |
| 111 | const keybindings = yield* Keybindings; | |
| 112 | const open = yield* Open; | |
| 113 | const gitManager = yield* GitManager; | |
| 114 | const git = yield* GitCore; | |
| 115 | const gitStatusBroadcaster = yield* GitStatusBroadcaster; | |
| 116 | const terminalManager = yield* TerminalManager; | |
| 117 | const providerRegistry = yield* ProviderRegistry; | |
| 118 | const config = yield* ServerConfig; | |
| 119 | const lifecycleEvents = yield* ServerLifecycleEvents; | |
| 120 | const serverSettings = yield* ServerSettingsService; | |
| 121 | const startup = yield* ServerRuntimeStartup; | |
| 122 | const workspaceEntries = yield* WorkspaceEntries; | |
| 123 | const workspaceFileSystem = yield* WorkspaceFileSystem; | |
| 124 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; | |
| 125 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | |
| 126 | const serverEnvironment = yield* ServerEnvironment; | |
| 127 | const serverAuth = yield* ServerAuth; | |
| 128 | const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 129 | const sessions = yield* SessionCredentialService; | |
| 130 | const serverCommandId = (tag: string) => | |
| 131 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); | |
| 108 | 132 | |
| 109 | const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause<unknown>) => { | |
| 110 | const error = Cause.squash(cause); | |
| 111 | return Schema.is(OrchestrationDispatchCommandError)(error) | |
| 112 | ? error | |
| 113 | : new OrchestrationDispatchCommandError({ | |
| 114 | message: | |
| 115 | error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", | |
| 116 | cause, | |
| 117 | }); | |
| 118 | }; | |
| 119 | ||
| 120 | const enrichProjectEvent = ( | |
| 121 | event: OrchestrationEvent, | |
| 122 | ): Effect.Effect<OrchestrationEvent, never, never> => { | |
| 123 | switch (event.type) { | |
| 124 | case "project.created": | |
| 125 | return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( | |
| 126 | Effect.map((repositoryIdentity) => ({ | |
| 127 | ...event, | |
| 128 | payload: { | |
| 129 | ...event.payload, | |
| 130 | repositoryIdentity, | |
| 131 | }, | |
| 132 | })), | |
| 133 | ); | |
| 134 | case "project.meta-updated": | |
| 135 | return Effect.gen(function* () { | |
| 136 | const workspaceRoot = | |
| 137 | event.payload.workspaceRoot ?? | |
| 138 | (yield* orchestrationEngine.getReadModel()).projects.find( | |
| 139 | (project) => project.id === event.payload.projectId, | |
| 140 | )?.workspaceRoot ?? | |
| 141 | null; | |
| 142 | if (workspaceRoot === null) { | |
| 143 | return event; | |
| 144 | } | |
| 133 | const loadAuthAccessSnapshot = () => | |
| 134 | Effect.all({ | |
| 135 | pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie), | |
| 136 | clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie), | |
| 137 | }); | |
| 145 | 138 | |
| 146 | const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); | |
| 147 | return { | |
| 148 | ...event, | |
| 149 | payload: { | |
| 150 | ...event.payload, | |
| 151 | repositoryIdentity, | |
| 152 | }, | |
| 153 | } satisfies OrchestrationEvent; | |
| 154 | }); | |
| 155 | default: | |
| 156 | return Effect.succeed(event); | |
| 157 | } | |
| 158 | }; | |
| 139 | const appendSetupScriptActivity = (input: { | |
| 140 | readonly threadId: ThreadId; | |
| 141 | readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; | |
| 142 | readonly summary: string; | |
| 143 | readonly createdAt: string; | |
| 144 | readonly payload: Record<string, unknown>; | |
| 145 | readonly tone: "info" | "error"; | |
| 146 | }) => | |
| 147 | orchestrationEngine.dispatch({ | |
| 148 | type: "thread.activity.append", | |
| 149 | commandId: serverCommandId("setup-script-activity"), | |
| 150 | threadId: input.threadId, | |
| 151 | activity: { | |
| 152 | id: EventId.makeUnsafe(crypto.randomUUID()), | |
| 153 | tone: input.tone, | |
| 154 | kind: input.kind, | |
| 155 | summary: input.summary, | |
| 156 | payload: input.payload, | |
| 157 | turnId: null, | |
| 158 | createdAt: input.createdAt, | |
| 159 | }, | |
| 160 | createdAt: input.createdAt, | |
| 161 | }); | |
| 159 | 162 | |
| 160 | const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) => | |
| 161 | Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); | |
| 163 | const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => | |
| 164 | Schema.is(OrchestrationDispatchCommandError)(cause) | |
| 165 | ? cause | |
| 166 | : new OrchestrationDispatchCommandError({ | |
| 167 | message: cause instanceof Error ? cause.message : fallbackMessage, | |
| 168 | cause, | |
| 169 | }); | |
| 162 | 170 | |
| 163 | const dispatchBootstrapTurnStart = ( | |
| 164 | command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>, | |
| 165 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => | |
| 166 | Effect.gen(function* () { | |
| 167 | const bootstrap = command.bootstrap; | |
| 168 | const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; | |
| 169 | let createdThread = false; | |
| 170 | let targetProjectId = bootstrap?.createThread?.projectId; | |
| 171 | let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; | |
| 172 | let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; | |
| 173 | ||
| 174 | const cleanupCreatedThread = () => | |
| 175 | createdThread | |
| 176 | ? orchestrationEngine | |
| 177 | .dispatch({ | |
| 178 | type: "thread.delete", | |
| 179 | commandId: serverCommandId("bootstrap-thread-delete"), | |
| 180 | threadId: command.threadId, | |
| 181 | }) | |
| 182 | .pipe(Effect.ignoreCause({ log: true })) | |
| 183 | : Effect.void; | |
| 184 | ||
| 185 | const recordSetupScriptLaunchFailure = (input: { | |
| 186 | readonly error: unknown; | |
| 187 | readonly requestedAt: string; | |
| 188 | readonly worktreePath: string; | |
| 189 | }) => { | |
| 190 | const detail = | |
| 191 | input.error instanceof Error ? input.error.message : "Unknown setup failure."; | |
| 192 | return appendSetupScriptActivity({ | |
| 193 | threadId: command.threadId, | |
| 194 | kind: "setup-script.failed", | |
| 195 | summary: "Setup script failed to start", | |
| 196 | createdAt: input.requestedAt, | |
| 197 | payload: { | |
| 198 | detail, | |
| 199 | worktreePath: input.worktreePath, | |
| 200 | }, | |
| 201 | tone: "error", | |
| 202 | }).pipe( | |
| 203 | Effect.ignoreCause({ log: false }), | |
| 204 | Effect.flatMap(() => | |
| 205 | Effect.logWarning("bootstrap turn start failed to launch setup script", { | |
| 206 | threadId: command.threadId, | |
| 207 | worktreePath: input.worktreePath, | |
| 208 | detail, | |
| 209 | }), | |
| 210 | ), | |
| 211 | ); | |
| 212 | }; | |
| 171 | const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause<unknown>) => { | |
| 172 | const error = Cause.squash(cause); | |
| 173 | return Schema.is(OrchestrationDispatchCommandError)(error) | |
| 174 | ? error | |
| 175 | : new OrchestrationDispatchCommandError({ | |
| 176 | message: | |
| 177 | error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", | |
| 178 | cause, | |
| 179 | }); | |
| 180 | }; | |
| 213 | 181 | |
| 214 | const recordSetupScriptStarted = (input: { | |
| 215 | readonly requestedAt: string; | |
| 216 | readonly worktreePath: string; | |
| 217 | readonly scriptId: string; | |
| 218 | readonly scriptName: string; | |
| 219 | readonly terminalId: string; | |
| 220 | }) => { | |
| 221 | const payload = { | |
| 222 | scriptId: input.scriptId, | |
| 223 | scriptName: input.scriptName, | |
| 224 | terminalId: input.terminalId, | |
| 225 | worktreePath: input.worktreePath, | |
| 226 | }; | |
| 227 | return Effect.all([ | |
| 228 | appendSetupScriptActivity({ | |
| 182 | const enrichProjectEvent = ( | |
| 183 | event: OrchestrationEvent, | |
| 184 | ): Effect.Effect<OrchestrationEvent, never, never> => { | |
| 185 | switch (event.type) { | |
| 186 | case "project.created": | |
| 187 | return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( | |
| 188 | Effect.map((repositoryIdentity) => ({ | |
| 189 | ...event, | |
| 190 | payload: { | |
| 191 | ...event.payload, | |
| 192 | repositoryIdentity, | |
| 193 | }, | |
| 194 | })), | |
| 195 | ); | |
| 196 | case "project.meta-updated": | |
| 197 | return Effect.gen(function* () { | |
| 198 | const workspaceRoot = | |
| 199 | event.payload.workspaceRoot ?? | |
| 200 | (yield* orchestrationEngine.getReadModel()).projects.find( | |
| 201 | (project) => project.id === event.payload.projectId, | |
| 202 | )?.workspaceRoot ?? | |
| 203 | null; | |
| 204 | if (workspaceRoot === null) { | |
| 205 | return event; | |
| 206 | } | |
| 207 | ||
| 208 | const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); | |
| 209 | return { | |
| 210 | ...event, | |
| 211 | payload: { | |
| 212 | ...event.payload, | |
| 213 | repositoryIdentity, | |
| 214 | }, | |
| 215 | } satisfies OrchestrationEvent; | |
| 216 | }); | |
| 217 | default: | |
| 218 | return Effect.succeed(event); | |
| 219 | } | |
| 220 | }; | |
| 221 | ||
| 222 | const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) => | |
| 223 | Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); | |
| 224 | ||
| 225 | const dispatchBootstrapTurnStart = ( | |
| 226 | command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>, | |
| 227 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => | |
| 228 | Effect.gen(function* () { | |
| 229 | const bootstrap = command.bootstrap; | |
| 230 | const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; | |
| 231 | let createdThread = false; | |
| 232 | let targetProjectId = bootstrap?.createThread?.projectId; | |
| 233 | let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; | |
| 234 | let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; | |
| 235 | ||
| 236 | const cleanupCreatedThread = () => | |
| 237 | createdThread | |
| 238 | ? orchestrationEngine | |
| 239 | .dispatch({ | |
| 240 | type: "thread.delete", | |
| 241 | commandId: serverCommandId("bootstrap-thread-delete"), | |
| 242 | threadId: command.threadId, | |
| 243 | }) | |
| 244 | .pipe(Effect.ignoreCause({ log: true })) | |
| 245 | : Effect.void; | |
| 246 | ||
| 247 | const recordSetupScriptLaunchFailure = (input: { | |
| 248 | readonly error: unknown; | |
| 249 | readonly requestedAt: string; | |
| 250 | readonly worktreePath: string; | |
| 251 | }) => { | |
| 252 | const detail = | |
| 253 | input.error instanceof Error ? input.error.message : "Unknown setup failure."; | |
| 254 | return appendSetupScriptActivity({ | |
| 229 | 255 | threadId: command.threadId, |
| 230 | kind: "setup-script.requested", | |
| 231 | summary: "Starting setup script", | |
| 256 | kind: "setup-script.failed", | |
| 257 | summary: "Setup script failed to start", | |
| 232 | 258 | createdAt: input.requestedAt, |
| 233 | payload, | |
| 234 | tone: "info", | |
| 235 | }), | |
| 236 | appendSetupScriptActivity({ | |
| 237 | threadId: command.threadId, | |
| 238 | kind: "setup-script.started", | |
| 239 | summary: "Setup script started", | |
| 240 | createdAt: new Date().toISOString(), | |
| 241 | payload, | |
| 242 | tone: "info", | |
| 243 | }), | |
| 244 | ]).pipe( | |
| 245 | Effect.asVoid, | |
| 246 | Effect.catch((error) => | |
| 247 | Effect.logWarning( | |
| 248 | "bootstrap turn start launched setup script but failed to record setup activity", | |
| 249 | { | |
| 259 | payload: { | |
| 260 | detail, | |
| 261 | worktreePath: input.worktreePath, | |
| 262 | }, | |
| 263 | tone: "error", | |
| 264 | }).pipe( | |
| 265 | Effect.ignoreCause({ log: false }), | |
| 266 | Effect.flatMap(() => | |
| 267 | Effect.logWarning("bootstrap turn start failed to launch setup script", { | |
| 250 | 268 | threadId: command.threadId, |
| 251 | 269 | worktreePath: input.worktreePath, |
| 252 | scriptId: input.scriptId, | |
| 253 | terminalId: input.terminalId, | |
| 254 | detail: | |
| 255 | error instanceof Error | |
| 256 | ? error.message | |
| 257 | : "Unknown setup activity dispatch failure.", | |
| 258 | }, | |
| 270 | detail, | |
| 271 | }), | |
| 259 | 272 | ), |
| 260 | ), | |
| 261 | ); | |
| 262 | }; | |
| 273 | ); | |
| 274 | }; | |
| 263 | 275 | |
| 264 | const runSetupProgram = () => | |
| 265 | bootstrap?.runSetupScript && targetWorktreePath | |
| 266 | ? (() => { | |
| 267 | const worktreePath = targetWorktreePath; | |
| 268 | const requestedAt = new Date().toISOString(); | |
| 269 | return projectSetupScriptRunner | |
| 270 | .runForThread({ | |
| 276 | const recordSetupScriptStarted = (input: { | |
| 277 | readonly requestedAt: string; | |
| 278 | readonly worktreePath: string; | |
| 279 | readonly scriptId: string; | |
| 280 | readonly scriptName: string; | |
| 281 | readonly terminalId: string; | |
| 282 | }) => { | |
| 283 | const payload = { | |
| 284 | scriptId: input.scriptId, | |
| 285 | scriptName: input.scriptName, | |
| 286 | terminalId: input.terminalId, | |
| 287 | worktreePath: input.worktreePath, | |
| 288 | }; | |
| 289 | return Effect.all([ | |
| 290 | appendSetupScriptActivity({ | |
| 291 | threadId: command.threadId, | |
| 292 | kind: "setup-script.requested", | |
| 293 | summary: "Starting setup script", | |
| 294 | createdAt: input.requestedAt, | |
| 295 | payload, | |
| 296 | tone: "info", | |
| 297 | }), | |
| 298 | appendSetupScriptActivity({ | |
| 299 | threadId: command.threadId, | |
| 300 | kind: "setup-script.started", | |
| 301 | summary: "Setup script started", | |
| 302 | createdAt: new Date().toISOString(), | |
| 303 | payload, | |
| 304 | tone: "info", | |
| 305 | }), | |
| 306 | ]).pipe( | |
| 307 | Effect.asVoid, | |
| 308 | Effect.catch((error) => | |
| 309 | Effect.logWarning( | |
| 310 | "bootstrap turn start launched setup script but failed to record setup activity", | |
| 311 | { | |
| 271 | 312 | threadId: command.threadId, |
| 272 | ...(targetProjectId ? { projectId: targetProjectId } : {}), | |
| 273 | ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), | |
| 274 | worktreePath, | |
| 275 | }) | |
| 276 | .pipe( | |
| 277 | Effect.matchEffect({ | |
| 278 | onFailure: (error) => | |
| 279 | recordSetupScriptLaunchFailure({ | |
| 280 | error, | |
| 281 | requestedAt, | |
| 282 | worktreePath, | |
| 283 | }), | |
| 284 | onSuccess: (setupResult) => { | |
| 285 | if (setupResult.status !== "started") { | |
| 286 | return Effect.void; | |
| 287 | } | |
| 288 | return recordSetupScriptStarted({ | |
| 289 | requestedAt, | |
| 290 | worktreePath, | |
| 291 | scriptId: setupResult.scriptId, | |
| 292 | scriptName: setupResult.scriptName, | |
| 293 | terminalId: setupResult.terminalId, | |
| 294 | }); | |
| 295 | }, | |
| 296 | }), | |
| 297 | ); | |
| 298 | })() | |
| 299 | : Effect.void; | |
| 300 | ||
| 301 | const bootstrapProgram = Effect.gen(function* () { | |
| 302 | if (bootstrap?.createThread) { | |
| 303 | yield* orchestrationEngine.dispatch({ | |
| 304 | type: "thread.create", | |
| 305 | commandId: serverCommandId("bootstrap-thread-create"), | |
| 306 | threadId: command.threadId, | |
| 307 | projectId: bootstrap.createThread.projectId, | |
| 308 | title: bootstrap.createThread.title, | |
| 309 | modelSelection: bootstrap.createThread.modelSelection, | |
| 310 | runtimeMode: bootstrap.createThread.runtimeMode, | |
| 311 | interactionMode: bootstrap.createThread.interactionMode, | |
| 312 | branch: bootstrap.createThread.branch, | |
| 313 | worktreePath: bootstrap.createThread.worktreePath, | |
| 314 | createdAt: bootstrap.createThread.createdAt, | |
| 315 | }); | |
| 316 | createdThread = true; | |
| 317 | } | |
| 318 | ||
| 319 | if (bootstrap?.prepareWorktree) { | |
| 320 | const worktree = yield* git.createWorktree({ | |
| 321 | cwd: bootstrap.prepareWorktree.projectCwd, | |
| 322 | branch: bootstrap.prepareWorktree.baseBranch, | |
| 323 | newBranch: bootstrap.prepareWorktree.branch, | |
| 324 | path: null, | |
| 325 | }); | |
| 326 | targetWorktreePath = worktree.worktree.path; | |
| 327 | yield* orchestrationEngine.dispatch({ | |
| 328 | type: "thread.meta.update", | |
| 329 | commandId: serverCommandId("bootstrap-thread-meta-update"), | |
| 330 | threadId: command.threadId, | |
| 331 | branch: worktree.worktree.branch, | |
| 332 | worktreePath: targetWorktreePath, | |
| 333 | }); | |
| 334 | } | |
| 313 | worktreePath: input.worktreePath, | |
| 314 | scriptId: input.scriptId, | |
| 315 | terminalId: input.terminalId, | |
| 316 | detail: | |
| 317 | error instanceof Error | |
| 318 | ? error.message | |
| 319 | : "Unknown setup activity dispatch failure.", | |
| 320 | }, | |
| 321 | ), | |
| 322 | ), | |
| 323 | ); | |
| 324 | }; | |
| 335 | 325 | |
| 336 | yield* runSetupProgram(); | |
| 326 | const runSetupProgram = () => | |
| 327 | bootstrap?.runSetupScript && targetWorktreePath | |
| 328 | ? (() => { | |
| 329 | const worktreePath = targetWorktreePath; | |
| 330 | const requestedAt = new Date().toISOString(); | |
| 331 | return projectSetupScriptRunner | |
| 332 | .runForThread({ | |
| 333 | threadId: command.threadId, | |
| 334 | ...(targetProjectId ? { projectId: targetProjectId } : {}), | |
| 335 | ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), | |
| 336 | worktreePath, | |
| 337 | }) | |
| 338 | .pipe( | |
| 339 | Effect.matchEffect({ | |
| 340 | onFailure: (error) => | |
| 341 | recordSetupScriptLaunchFailure({ | |
| 342 | error, | |
| 343 | requestedAt, | |
| 344 | worktreePath, | |
| 345 | }), | |
| 346 | onSuccess: (setupResult) => { | |
| 347 | if (setupResult.status !== "started") { | |
| 348 | return Effect.void; | |
| 349 | } | |
| 350 | return recordSetupScriptStarted({ | |
| 351 | requestedAt, | |
| 352 | worktreePath, | |
| 353 | scriptId: setupResult.scriptId, | |
| 354 | scriptName: setupResult.scriptName, | |
| 355 | terminalId: setupResult.terminalId, | |
| 356 | }); | |
| 357 | }, | |
| 358 | }), | |
| 359 | ); | |
| 360 | })() | |
| 361 | : Effect.void; | |
| 337 | 362 | |
| 338 | return yield* orchestrationEngine.dispatch(finalTurnStartCommand); | |
| 339 | }); | |
| 363 | const bootstrapProgram = Effect.gen(function* () { | |
| 364 | if (bootstrap?.createThread) { | |
| 365 | yield* orchestrationEngine.dispatch({ | |
| 366 | type: "thread.create", | |
| 367 | commandId: serverCommandId("bootstrap-thread-create"), | |
| 368 | threadId: command.threadId, | |
| 369 | projectId: bootstrap.createThread.projectId, | |
| 370 | title: bootstrap.createThread.title, | |
| 371 | modelSelection: bootstrap.createThread.modelSelection, | |
| 372 | runtimeMode: bootstrap.createThread.runtimeMode, | |
| 373 | interactionMode: bootstrap.createThread.interactionMode, | |
| 374 | branch: bootstrap.createThread.branch, | |
| 375 | worktreePath: bootstrap.createThread.worktreePath, | |
| 376 | createdAt: bootstrap.createThread.createdAt, | |
| 377 | }); | |
| 378 | createdThread = true; | |
| 379 | } | |
| 340 | 380 | |
| 341 | return yield* bootstrapProgram.pipe( | |
| 342 | Effect.catchCause((cause) => { | |
| 343 | const dispatchError = toBootstrapDispatchCommandCauseError(cause); | |
| 344 | if (Cause.hasInterruptsOnly(cause)) { | |
| 345 | return Effect.fail(dispatchError); | |
| 381 | if (bootstrap?.prepareWorktree) { | |
| 382 | const worktree = yield* git.createWorktree({ | |
| 383 | cwd: bootstrap.prepareWorktree.projectCwd, | |
| 384 | branch: bootstrap.prepareWorktree.baseBranch, | |
| 385 | newBranch: bootstrap.prepareWorktree.branch, | |
| 386 | path: null, | |
| 387 | }); | |
| 388 | targetWorktreePath = worktree.worktree.path; | |
| 389 | yield* orchestrationEngine.dispatch({ | |
| 390 | type: "thread.meta.update", | |
| 391 | commandId: serverCommandId("bootstrap-thread-meta-update"), | |
| 392 | threadId: command.threadId, | |
| 393 | branch: worktree.worktree.branch, | |
| 394 | worktreePath: targetWorktreePath, | |
| 395 | }); | |
| 346 | 396 | } |
| 347 | return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); | |
| 348 | }), | |
| 349 | ); | |
| 350 | }); | |
| 351 | 397 | |
| 352 | const dispatchNormalizedCommand = ( | |
| 353 | normalizedCommand: OrchestrationCommand, | |
| 354 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { | |
| 355 | const dispatchEffect = | |
| 356 | normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap | |
| 357 | ? dispatchBootstrapTurnStart(normalizedCommand) | |
| 358 | : orchestrationEngine | |
| 359 | .dispatch(normalizedCommand) | |
| 360 | .pipe( | |
| 361 | Effect.mapError((cause) => | |
| 362 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), | |
| 363 | ), | |
| 364 | ); | |
| 398 | yield* runSetupProgram(); | |
| 365 | 399 | |
| 366 | return startup | |
| 367 | .enqueueCommand(dispatchEffect) | |
| 368 | .pipe( | |
| 369 | Effect.mapError((cause) => | |
| 370 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), | |
| 371 | ), | |
| 372 | ); | |
| 373 | }; | |
| 400 | return yield* orchestrationEngine.dispatch(finalTurnStartCommand); | |
| 401 | }); | |
| 374 | 402 | |
| 375 | const loadServerConfig = Effect.gen(function* () { | |
| 376 | const keybindingsConfig = yield* keybindings.loadConfigState; | |
| 377 | const providers = yield* providerRegistry.getProviders; | |
| 378 | const settings = yield* serverSettings.getSettings; | |
| 379 | const environment = yield* serverEnvironment.getDescriptor; | |
| 403 | return yield* bootstrapProgram.pipe( | |
| 404 | Effect.catchCause((cause) => { | |
| 405 | const dispatchError = toBootstrapDispatchCommandCauseError(cause); | |
| 406 | if (Cause.hasInterruptsOnly(cause)) { | |
| 407 | return Effect.fail(dispatchError); | |
| 408 | } | |
| 409 | return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); | |
| 410 | }), | |
| 411 | ); | |
| 412 | }); | |
| 380 | 413 | |
| 381 | return { | |
| 382 | environment, | |
| 383 | cwd: config.cwd, | |
| 384 | keybindingsConfigPath: config.keybindingsConfigPath, | |
| 385 | keybindings: keybindingsConfig.keybindings, | |
| 386 | issues: keybindingsConfig.issues, | |
| 387 | providers, | |
| 388 | availableEditors: resolveAvailableEditors(), | |
| 389 | observability: { | |
| 390 | logsDirectoryPath: config.logsDir, | |
| 391 | localTracingEnabled: true, | |
| 392 | ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), | |
| 393 | otlpTracesEnabled: config.otlpTracesUrl !== undefined, | |
| 394 | ...(config.otlpMetricsUrl !== undefined ? { otlpMetricsUrl: config.otlpMetricsUrl } : {}), | |
| 395 | otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, | |
| 396 | }, | |
| 397 | settings, | |
| 398 | }; | |
| 399 | }); | |
| 400 | ||
| 401 | const refreshGitStatus = (cwd: string) => | |
| 402 | gitStatusBroadcaster | |
| 403 | .refreshStatus(cwd) | |
| 404 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); | |
| 405 | ||
| 406 | return WsRpcGroup.of({ | |
| 407 | [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => | |
| 408 | observeRpcEffect( | |
| 409 | ORCHESTRATION_WS_METHODS.getSnapshot, | |
| 410 | projectionSnapshotQuery.getSnapshot().pipe( | |
| 411 | Effect.mapError( | |
| 412 | (cause) => | |
| 413 | new OrchestrationGetSnapshotError({ | |
| 414 | message: "Failed to load orchestration snapshot", | |
| 415 | cause, | |
| 416 | }), | |
| 417 | ), | |
| 418 | ), | |
| 419 | { "rpc.aggregate": "orchestration" }, | |
| 420 | ), | |
| 421 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => | |
| 422 | observeRpcEffect( | |
| 423 | ORCHESTRATION_WS_METHODS.dispatchCommand, | |
| 424 | Effect.gen(function* () { | |
| 425 | const normalizedCommand = yield* normalizeDispatchCommand(command); | |
| 426 | const result = yield* dispatchNormalizedCommand(normalizedCommand); | |
| 427 | if (normalizedCommand.type === "thread.archive") { | |
| 428 | yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( | |
| 429 | Effect.catch((error) => | |
| 430 | Effect.logWarning("failed to close thread terminals after archive", { | |
| 431 | threadId: normalizedCommand.threadId, | |
| 432 | error: error.message, | |
| 433 | }), | |
| 434 | ), | |
| 435 | ); | |
| 436 | } | |
| 437 | return result; | |
| 438 | }).pipe( | |
| 414 | const dispatchNormalizedCommand = ( | |
| 415 | normalizedCommand: OrchestrationCommand, | |
| 416 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { | |
| 417 | const dispatchEffect = | |
| 418 | normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap | |
| 419 | ? dispatchBootstrapTurnStart(normalizedCommand) | |
| 420 | : orchestrationEngine | |
| 421 | .dispatch(normalizedCommand) | |
| 422 | .pipe( | |
| 423 | Effect.mapError((cause) => | |
| 424 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), | |
| 425 | ), | |
| 426 | ); | |
| 427 | ||
| 428 | return startup | |
| 429 | .enqueueCommand(dispatchEffect) | |
| 430 | .pipe( | |
| 439 | 431 | Effect.mapError((cause) => |
| 440 | Schema.is(OrchestrationDispatchCommandError)(cause) | |
| 441 | ? cause | |
| 442 | : new OrchestrationDispatchCommandError({ | |
| 443 | message: "Failed to dispatch orchestration command", | |
| 432 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), | |
| 433 | ), | |
| 434 | ); | |
| 435 | }; | |
| 436 | ||
| 437 | const loadServerConfig = Effect.gen(function* () { | |
| 438 | const keybindingsConfig = yield* keybindings.loadConfigState; | |
| 439 | const providers = yield* providerRegistry.getProviders; | |
| 440 | const settings = yield* serverSettings.getSettings; | |
| 441 | const environment = yield* serverEnvironment.getDescriptor; | |
| 442 | const auth = yield* serverAuth.getDescriptor(); | |
| 443 | ||
| 444 | return { | |
| 445 | environment, | |
| 446 | auth, | |
| 447 | cwd: config.cwd, | |
| 448 | keybindingsConfigPath: config.keybindingsConfigPath, | |
| 449 | keybindings: keybindingsConfig.keybindings, | |
| 450 | issues: keybindingsConfig.issues, | |
| 451 | providers, | |
| 452 | availableEditors: resolveAvailableEditors(), | |
| 453 | observability: { | |
| 454 | logsDirectoryPath: config.logsDir, | |
| 455 | localTracingEnabled: true, | |
| 456 | ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), | |
| 457 | otlpTracesEnabled: config.otlpTracesUrl !== undefined, | |
| 458 | ...(config.otlpMetricsUrl !== undefined | |
| 459 | ? { otlpMetricsUrl: config.otlpMetricsUrl } | |
| 460 | : {}), | |
| 461 | otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, | |
| 462 | }, | |
| 463 | settings, | |
| 464 | }; | |
| 465 | }); | |
| 466 | ||
| 467 | const refreshGitStatus = (cwd: string) => | |
| 468 | gitStatusBroadcaster | |
| 469 | .refreshStatus(cwd) | |
| 470 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); | |
| 471 | ||
| 472 | return WsRpcGroup.of({ | |
| 473 | [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => | |
| 474 | observeRpcEffect( | |
| 475 | ORCHESTRATION_WS_METHODS.getSnapshot, | |
| 476 | projectionSnapshotQuery.getSnapshot().pipe( | |
| 477 | Effect.mapError( | |
| 478 | (cause) => | |
| 479 | new OrchestrationGetSnapshotError({ | |
| 480 | message: "Failed to load orchestration snapshot", | |
| 444 | 481 | cause, |
| 445 | 482 | }), |
| 483 | ), | |
| 446 | 484 | ), |
| 485 | { "rpc.aggregate": "orchestration" }, | |
| 447 | 486 | ), |
| 448 | { "rpc.aggregate": "orchestration" }, | |
| 449 | ), | |
| 450 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => | |
| 451 | observeRpcEffect( | |
| 452 | ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 453 | checkpointDiffQuery.getTurnDiff(input).pipe( | |
| 454 | Effect.mapError( | |
| 455 | (cause) => | |
| 456 | new OrchestrationGetTurnDiffError({ | |
| 457 | message: "Failed to load turn diff", | |
| 458 | cause, | |
| 459 | }), | |
| 487 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => | |
| 488 | observeRpcEffect( | |
| 489 | ORCHESTRATION_WS_METHODS.dispatchCommand, | |
| 490 | Effect.gen(function* () { | |
| 491 | const normalizedCommand = yield* normalizeDispatchCommand(command); | |
| 492 | const result = yield* dispatchNormalizedCommand(normalizedCommand); | |
| 493 | if (normalizedCommand.type === "thread.archive") { | |
| 494 | yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( | |
| 495 | Effect.catch((error) => | |
| 496 | Effect.logWarning("failed to close thread terminals after archive", { | |
| 497 | threadId: normalizedCommand.threadId, | |
| 498 | error: error.message, | |
| 499 | }), | |
| 500 | ), | |
| 501 | ); | |
| 502 | } | |
| 503 | return result; | |
| 504 | }).pipe( | |
| 505 | Effect.mapError((cause) => | |
| 506 | Schema.is(OrchestrationDispatchCommandError)(cause) | |
| 507 | ? cause | |
| 508 | : new OrchestrationDispatchCommandError({ | |
| 509 | message: "Failed to dispatch orchestration command", | |
| 510 | cause, | |
| 511 | }), | |
| 512 | ), | |
| 460 | 513 | ), |
| 514 | { "rpc.aggregate": "orchestration" }, | |
| 461 | 515 | ), |
| 462 | { "rpc.aggregate": "orchestration" }, | |
| 463 | ), | |
| 464 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => | |
| 465 | observeRpcEffect( | |
| 466 | ORCHESTRATION_WS_METHODS.getFullThreadDiff, | |
| 467 | checkpointDiffQuery.getFullThreadDiff(input).pipe( | |
| 468 | Effect.mapError( | |
| 469 | (cause) => | |
| 470 | new OrchestrationGetFullThreadDiffError({ | |
| 471 | message: "Failed to load full thread diff", | |
| 472 | cause, | |
| 473 | }), | |
| 516 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => | |
| 517 | observeRpcEffect( | |
| 518 | ORCHESTRATION_WS_METHODS.getTurnDiff, | |
| 519 | checkpointDiffQuery.getTurnDiff(input).pipe( | |
| 520 | Effect.mapError( | |
| 521 | (cause) => | |
| 522 | new OrchestrationGetTurnDiffError({ | |
| 523 | message: "Failed to load turn diff", | |
| 524 | cause, | |
| 525 | }), | |
| 526 | ), | |
| 474 | 527 | ), |
| 528 | { "rpc.aggregate": "orchestration" }, | |
| 475 | 529 | ), |
| 476 | { "rpc.aggregate": "orchestration" }, | |
| 477 | ), | |
| 478 | [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => | |
| 479 | observeRpcEffect( | |
| 480 | ORCHESTRATION_WS_METHODS.replayEvents, | |
| 481 | Stream.runCollect( | |
| 482 | orchestrationEngine.readEvents( | |
| 483 | clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), | |
| 484 | ), | |
| 485 | ).pipe( | |
| 486 | Effect.map((events) => Array.from(events)), | |
| 487 | Effect.flatMap(enrichOrchestrationEvents), | |
| 488 | Effect.mapError( | |
| 489 | (cause) => | |
| 490 | new OrchestrationReplayEventsError({ | |
| 491 | message: "Failed to replay orchestration events", | |
| 492 | cause, | |
| 493 | }), | |
| 530 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => | |
| 531 | observeRpcEffect( | |
| 532 | ORCHESTRATION_WS_METHODS.getFullThreadDiff, | |
| 533 | checkpointDiffQuery.getFullThreadDiff(input).pipe( | |
| 534 | Effect.mapError( | |
| 535 | (cause) => | |
| 536 | new OrchestrationGetFullThreadDiffError({ | |
| 537 | message: "Failed to load full thread diff", | |
| 538 | cause, | |
| 539 | }), | |
| 540 | ), | |
| 494 | 541 | ), |
| 542 | { "rpc.aggregate": "orchestration" }, | |
| 495 | 543 | ), |
| 496 | { "rpc.aggregate": "orchestration" }, | |
| 497 | ), | |
| 498 | [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => | |
| 499 | observeRpcStreamEffect( | |
| 500 | WS_METHODS.subscribeOrchestrationDomainEvents, | |
| 501 | Effect.gen(function* () { | |
| 502 | const snapshot = yield* orchestrationEngine.getReadModel(); | |
| 503 | const fromSequenceExclusive = snapshot.snapshotSequence; | |
| 504 | const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect( | |
| 505 | orchestrationEngine.readEvents(fromSequenceExclusive), | |
| 544 | [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => | |
| 545 | observeRpcEffect( | |
| 546 | ORCHESTRATION_WS_METHODS.replayEvents, | |
| 547 | Stream.runCollect( | |
| 548 | orchestrationEngine.readEvents( | |
| 549 | clamp(input.fromSequenceExclusive, { | |
| 550 | maximum: Number.MAX_SAFE_INTEGER, | |
| 551 | minimum: 0, | |
| 552 | }), | |
| 553 | ), | |
| 506 | 554 | ).pipe( |
| 507 | 555 | Effect.map((events) => Array.from(events)), |
| 508 | 556 | Effect.flatMap(enrichOrchestrationEvents), |
| 509 | Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)), | |
| 510 | ); | |
| 511 | const replayStream = Stream.fromIterable(replayEvents); | |
| 512 | const liveStream = orchestrationEngine.streamDomainEvents.pipe( | |
| 513 | Stream.mapEffect(enrichProjectEvent), | |
| 514 | ); | |
| 515 | const source = Stream.merge(replayStream, liveStream); | |
| 516 | type SequenceState = { | |
| 517 | readonly nextSequence: number; | |
| 518 | readonly pendingBySequence: Map<number, OrchestrationEvent>; | |
| 519 | }; | |
| 520 | const state = yield* Ref.make<SequenceState>({ | |
| 521 | nextSequence: fromSequenceExclusive + 1, | |
| 522 | pendingBySequence: new Map<number, OrchestrationEvent>(), | |
| 523 | }); | |
| 557 | Effect.mapError( | |
| 558 | (cause) => | |
| 559 | new OrchestrationReplayEventsError({ | |
| 560 | message: "Failed to replay orchestration events", | |
| 561 | cause, | |
| 562 | }), | |
| 563 | ), | |
| 564 | ), | |
| 565 | { "rpc.aggregate": "orchestration" }, | |
| 566 | ), | |
| 567 | [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => | |
| 568 | observeRpcStreamEffect( | |
| 569 | WS_METHODS.subscribeOrchestrationDomainEvents, | |
| 570 | Effect.gen(function* () { | |
| 571 | const snapshot = yield* orchestrationEngine.getReadModel(); | |
| 572 | const fromSequenceExclusive = snapshot.snapshotSequence; | |
| 573 | const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect( | |
| 574 | orchestrationEngine.readEvents(fromSequenceExclusive), | |
| 575 | ).pipe( | |
| 576 | Effect.map((events) => Array.from(events)), | |
| 577 | Effect.flatMap(enrichOrchestrationEvents), | |
| 578 | Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)), | |
| 579 | ); | |
| 580 | const replayStream = Stream.fromIterable(replayEvents); | |
| 581 | const liveStream = orchestrationEngine.streamDomainEvents.pipe( | |
| 582 | Stream.mapEffect(enrichProjectEvent), | |
| 583 | ); | |
| 584 | const source = Stream.merge(replayStream, liveStream); | |
| 585 | type SequenceState = { | |
| 586 | readonly nextSequence: number; | |
| 587 | readonly pendingBySequence: Map<number, OrchestrationEvent>; | |
| 588 | }; | |
| 589 | const state = yield* Ref.make<SequenceState>({ | |
| 590 | nextSequence: fromSequenceExclusive + 1, | |
| 591 | pendingBySequence: new Map<number, OrchestrationEvent>(), | |
| 592 | }); | |
| 524 | 593 | |
| 525 | return source.pipe( | |
| 526 | Stream.mapEffect((event) => | |
| 527 | Ref.modify( | |
| 528 | state, | |
| 529 | ({ | |
| 530 | nextSequence, | |
| 531 | pendingBySequence, | |
| 532 | }): [Array<OrchestrationEvent>, SequenceState] => { | |
| 533 | if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { | |
| 534 | return [[], { nextSequence, pendingBySequence }]; | |
| 535 | } | |
| 536 | ||
| 537 | const updatedPending = new Map(pendingBySequence); | |
| 538 | updatedPending.set(event.sequence, event); | |
| 539 | ||
| 540 | const emit: Array<OrchestrationEvent> = []; | |
| 541 | let expected = nextSequence; | |
| 542 | for (;;) { | |
| 543 | const expectedEvent = updatedPending.get(expected); | |
| 544 | if (!expectedEvent) { | |
| 545 | break; | |
| 594 | return source.pipe( | |
| 595 | Stream.mapEffect((event) => | |
| 596 | Ref.modify( | |
| 597 | state, | |
| 598 | ({ | |
| 599 | nextSequence, | |
| 600 | pendingBySequence, | |
| 601 | }): [Array<OrchestrationEvent>, SequenceState] => { | |
| 602 | if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { | |
| 603 | return [[], { nextSequence, pendingBySequence }]; | |
| 546 | 604 | } |
| 547 | emit.push(expectedEvent); | |
| 548 | updatedPending.delete(expected); | |
| 549 | expected += 1; | |
| 550 | } | |
| 551 | 605 | |
| 552 | return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; | |
| 553 | }, | |
| 606 | const updatedPending = new Map(pendingBySequence); | |
| 607 | updatedPending.set(event.sequence, event); | |
| 608 | ||
| 609 | const emit: Array<OrchestrationEvent> = []; | |
| 610 | let expected = nextSequence; | |
| 611 | for (;;) { | |
| 612 | const expectedEvent = updatedPending.get(expected); | |
| 613 | if (!expectedEvent) { | |
| 614 | break; | |
| 615 | } | |
| 616 | emit.push(expectedEvent); | |
| 617 | updatedPending.delete(expected); | |
| 618 | expected += 1; | |
| 619 | } | |
| 620 | ||
| 621 | return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; | |
| 622 | }, | |
| 623 | ), | |
| 554 | 624 | ), |
| 555 | ), | |
| 556 | Stream.flatMap((events) => Stream.fromIterable(events)), | |
| 557 | ); | |
| 625 | Stream.flatMap((events) => Stream.fromIterable(events)), | |
| 626 | ); | |
| 627 | }), | |
| 628 | { "rpc.aggregate": "orchestration" }, | |
| 629 | ), | |
| 630 | [WS_METHODS.serverGetConfig]: (_input) => | |
| 631 | observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { | |
| 632 | "rpc.aggregate": "server", | |
| 558 | 633 | }), |
| 559 | { "rpc.aggregate": "orchestration" }, | |
| 560 | ), | |
| 561 | [WS_METHODS.serverGetConfig]: (_input) => | |
| 562 | observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { | |
| 563 | "rpc.aggregate": "server", | |
| 564 | }), | |
| 565 | [WS_METHODS.serverRefreshProviders]: (_input) => | |
| 566 | observeRpcEffect( | |
| 567 | WS_METHODS.serverRefreshProviders, | |
| 568 | providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), | |
| 569 | { "rpc.aggregate": "server" }, | |
| 570 | ), | |
| 571 | [WS_METHODS.serverUpsertKeybinding]: (rule) => | |
| 572 | observeRpcEffect( | |
| 573 | WS_METHODS.serverUpsertKeybinding, | |
| 574 | Effect.gen(function* () { | |
| 575 | const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); | |
| 576 | return { keybindings: keybindingsConfig, issues: [] }; | |
| 634 | [WS_METHODS.serverRefreshProviders]: (_input) => | |
| 635 | observeRpcEffect( | |
| 636 | WS_METHODS.serverRefreshProviders, | |
| 637 | providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), | |
| 638 | { "rpc.aggregate": "server" }, | |
| 639 | ), | |
| 640 | [WS_METHODS.serverUpsertKeybinding]: (rule) => | |
| 641 | observeRpcEffect( | |
| 642 | WS_METHODS.serverUpsertKeybinding, | |
| 643 | Effect.gen(function* () { | |
| 644 | const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); | |
| 645 | return { keybindings: keybindingsConfig, issues: [] }; | |
| 646 | }), | |
| 647 | { "rpc.aggregate": "server" }, | |
| 648 | ), | |
| 649 | [WS_METHODS.serverGetSettings]: (_input) => | |
| 650 | observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { | |
| 651 | "rpc.aggregate": "server", | |
| 652 | }), | |
| 653 | [WS_METHODS.serverUpdateSettings]: ({ patch }) => | |
| 654 | observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { | |
| 655 | "rpc.aggregate": "server", | |
| 577 | 656 | }), |
| 578 | { "rpc.aggregate": "server" }, | |
| 579 | ), | |
| 580 | [WS_METHODS.serverGetSettings]: (_input) => | |
| 581 | observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { | |
| 582 | "rpc.aggregate": "server", | |
| 583 | }), | |
| 584 | [WS_METHODS.serverUpdateSettings]: ({ patch }) => | |
| 585 | observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { | |
| 586 | "rpc.aggregate": "server", | |
| 587 | }), | |
| 588 | [WS_METHODS.projectsSearchEntries]: (input) => | |
| 589 | observeRpcEffect( | |
| 590 | WS_METHODS.projectsSearchEntries, | |
| 591 | workspaceEntries.search(input).pipe( | |
| 592 | Effect.mapError( | |
| 593 | (cause) => | |
| 594 | new ProjectSearchEntriesError({ | |
| 595 | message: `Failed to search workspace entries: ${cause.detail}`, | |
| 657 | [WS_METHODS.projectsSearchEntries]: (input) => | |
| 658 | observeRpcEffect( | |
| 659 | WS_METHODS.projectsSearchEntries, | |
| 660 | workspaceEntries.search(input).pipe( | |
| 661 | Effect.mapError( | |
| 662 | (cause) => | |
| 663 | new ProjectSearchEntriesError({ | |
| 664 | message: `Failed to search workspace entries: ${cause.detail}`, | |
| 665 | cause, | |
| 666 | }), | |
| 667 | ), | |
| 668 | ), | |
| 669 | { "rpc.aggregate": "workspace" }, | |
| 670 | ), | |
| 671 | [WS_METHODS.projectsWriteFile]: (input) => | |
| 672 | observeRpcEffect( | |
| 673 | WS_METHODS.projectsWriteFile, | |
| 674 | workspaceFileSystem.writeFile(input).pipe( | |
| 675 | Effect.mapError((cause) => { | |
| 676 | const message = Schema.is(WorkspacePathOutsideRootError)(cause) | |
| 677 | ? "Workspace file path must stay within the project root." | |
| 678 | : "Failed to write workspace file"; | |
| 679 | return new ProjectWriteFileError({ | |
| 680 | message, | |
| 596 | 681 | cause, |
| 597 | }), | |
| 682 | }); | |
| 683 | }), | |
| 598 | 684 | ), |
| 685 | { "rpc.aggregate": "workspace" }, | |
| 599 | 686 | ), |
| 600 | { "rpc.aggregate": "workspace" }, | |
| 601 | ), | |
| 602 | [WS_METHODS.projectsWriteFile]: (input) => | |
| 603 | observeRpcEffect( | |
| 604 | WS_METHODS.projectsWriteFile, | |
| 605 | workspaceFileSystem.writeFile(input).pipe( | |
| 606 | Effect.mapError((cause) => { | |
| 607 | const message = Schema.is(WorkspacePathOutsideRootError)(cause) | |
| 608 | ? "Workspace file path must stay within the project root." | |
| 609 | : "Failed to write workspace file"; | |
| 610 | return new ProjectWriteFileError({ | |
| 611 | message, | |
| 612 | cause, | |
| 613 | }); | |
| 614 | }), | |
| 687 | [WS_METHODS.shellOpenInEditor]: (input) => | |
| 688 | observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { | |
| 689 | "rpc.aggregate": "workspace", | |
| 690 | }), | |
| 691 | [WS_METHODS.subscribeGitStatus]: (input) => | |
| 692 | observeRpcStream( | |
| 693 | WS_METHODS.subscribeGitStatus, | |
| 694 | gitStatusBroadcaster.streamStatus(input), | |
| 695 | { | |
| 696 | "rpc.aggregate": "git", | |
| 697 | }, | |
| 615 | 698 | ), |
| 616 | { "rpc.aggregate": "workspace" }, | |
| 617 | ), | |
| 618 | [WS_METHODS.shellOpenInEditor]: (input) => | |
| 619 | observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { | |
| 620 | "rpc.aggregate": "workspace", | |
| 621 | }), | |
| 622 | [WS_METHODS.subscribeGitStatus]: (input) => | |
| 623 | observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), { | |
| 624 | "rpc.aggregate": "git", | |
| 625 | }), | |
| 626 | [WS_METHODS.gitRefreshStatus]: (input) => | |
| 627 | observeRpcEffect( | |
| 628 | WS_METHODS.gitRefreshStatus, | |
| 629 | gitStatusBroadcaster.refreshStatus(input.cwd), | |
| 630 | { | |
| 631 | "rpc.aggregate": "git", | |
| 632 | }, | |
| 633 | ), | |
| 634 | [WS_METHODS.gitPull]: (input) => | |
| 635 | observeRpcEffect( | |
| 636 | WS_METHODS.gitPull, | |
| 637 | git.pullCurrentBranch(input.cwd).pipe( | |
| 638 | Effect.matchCauseEffect({ | |
| 639 | onFailure: (cause) => Effect.failCause(cause), | |
| 640 | onSuccess: (result) => | |
| 641 | refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), | |
| 642 | }), | |
| 699 | [WS_METHODS.gitRefreshStatus]: (input) => | |
| 700 | observeRpcEffect( | |
| 701 | WS_METHODS.gitRefreshStatus, | |
| 702 | gitStatusBroadcaster.refreshStatus(input.cwd), | |
| 703 | { | |
| 704 | "rpc.aggregate": "git", | |
| 705 | }, | |
| 706 | ), | |
| 707 | [WS_METHODS.gitPull]: (input) => | |
| 708 | observeRpcEffect( | |
| 709 | WS_METHODS.gitPull, | |
| 710 | git.pullCurrentBranch(input.cwd).pipe( | |
| 711 | Effect.matchCauseEffect({ | |
| 712 | onFailure: (cause) => Effect.failCause(cause), | |
| 713 | onSuccess: (result) => | |
| 714 | refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), | |
| 715 | }), | |
| 716 | ), | |
| 717 | { "rpc.aggregate": "git" }, | |
| 643 | 718 | ), |
| 644 | { "rpc.aggregate": "git" }, | |
| 645 | ), | |
| 646 | [WS_METHODS.gitRunStackedAction]: (input) => | |
| 647 | observeRpcStream( | |
| 648 | WS_METHODS.gitRunStackedAction, | |
| 649 | Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) => | |
| 719 | [WS_METHODS.gitRunStackedAction]: (input) => | |
| 720 | observeRpcStream( | |
| 721 | WS_METHODS.gitRunStackedAction, | |
| 722 | Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) => | |
| 723 | gitManager | |
| 724 | .runStackedAction(input, { | |
| 725 | actionId: input.actionId, | |
| 726 | progressReporter: { | |
| 727 | publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), | |
| 728 | }, | |
| 729 | }) | |
| 730 | .pipe( | |
| 731 | Effect.matchCauseEffect({ | |
| 732 | onFailure: (cause) => Queue.failCause(queue, cause), | |
| 733 | onSuccess: () => | |
| 734 | refreshGitStatus(input.cwd).pipe( | |
| 735 | Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), | |
| 736 | ), | |
| 737 | }), | |
| 738 | ), | |
| 739 | ), | |
| 740 | { "rpc.aggregate": "git" }, | |
| 741 | ), | |
| 742 | [WS_METHODS.gitResolvePullRequest]: (input) => | |
| 743 | observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { | |
| 744 | "rpc.aggregate": "git", | |
| 745 | }), | |
| 746 | [WS_METHODS.gitPreparePullRequestThread]: (input) => | |
| 747 | observeRpcEffect( | |
| 748 | WS_METHODS.gitPreparePullRequestThread, | |
| 650 | 749 | gitManager |
| 651 | .runStackedAction(input, { | |
| 652 | actionId: input.actionId, | |
| 653 | progressReporter: { | |
| 654 | publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), | |
| 655 | }, | |
| 656 | }) | |
| 657 | .pipe( | |
| 658 | Effect.matchCauseEffect({ | |
| 659 | onFailure: (cause) => Queue.failCause(queue, cause), | |
| 660 | onSuccess: () => | |
| 661 | refreshGitStatus(input.cwd).pipe( | |
| 662 | Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), | |
| 663 | ), | |
| 664 | }), | |
| 665 | ), | |
| 750 | .preparePullRequestThread(input) | |
| 751 | .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 752 | { "rpc.aggregate": "git" }, | |
| 753 | ), | |
| 754 | [WS_METHODS.gitListBranches]: (input) => | |
| 755 | observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { | |
| 756 | "rpc.aggregate": "git", | |
| 757 | }), | |
| 758 | [WS_METHODS.gitCreateWorktree]: (input) => | |
| 759 | observeRpcEffect( | |
| 760 | WS_METHODS.gitCreateWorktree, | |
| 761 | git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 762 | { "rpc.aggregate": "git" }, | |
| 763 | ), | |
| 764 | [WS_METHODS.gitRemoveWorktree]: (input) => | |
| 765 | observeRpcEffect( | |
| 766 | WS_METHODS.gitRemoveWorktree, | |
| 767 | git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 768 | { "rpc.aggregate": "git" }, | |
| 666 | 769 | ), |
| 667 | { "rpc.aggregate": "git" }, | |
| 668 | ), | |
| 669 | [WS_METHODS.gitResolvePullRequest]: (input) => | |
| 670 | observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { | |
| 671 | "rpc.aggregate": "git", | |
| 672 | }), | |
| 673 | [WS_METHODS.gitPreparePullRequestThread]: (input) => | |
| 674 | observeRpcEffect( | |
| 675 | WS_METHODS.gitPreparePullRequestThread, | |
| 676 | gitManager | |
| 677 | .preparePullRequestThread(input) | |
| 678 | .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 679 | { "rpc.aggregate": "git" }, | |
| 680 | ), | |
| 681 | [WS_METHODS.gitListBranches]: (input) => | |
| 682 | observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { | |
| 683 | "rpc.aggregate": "git", | |
| 684 | }), | |
| 685 | [WS_METHODS.gitCreateWorktree]: (input) => | |
| 686 | observeRpcEffect( | |
| 687 | WS_METHODS.gitCreateWorktree, | |
| 688 | git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 689 | { "rpc.aggregate": "git" }, | |
| 690 | ), | |
| 691 | [WS_METHODS.gitRemoveWorktree]: (input) => | |
| 692 | observeRpcEffect( | |
| 693 | WS_METHODS.gitRemoveWorktree, | |
| 694 | git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 695 | { "rpc.aggregate": "git" }, | |
| 696 | ), | |
| 697 | [WS_METHODS.gitCreateBranch]: (input) => | |
| 698 | observeRpcEffect( | |
| 699 | WS_METHODS.gitCreateBranch, | |
| 700 | git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 701 | { "rpc.aggregate": "git" }, | |
| 702 | ), | |
| 703 | [WS_METHODS.gitCheckout]: (input) => | |
| 704 | observeRpcEffect( | |
| 705 | WS_METHODS.gitCheckout, | |
| 706 | Effect.scoped(git.checkoutBranch(input)).pipe( | |
| 707 | Effect.tap(() => refreshGitStatus(input.cwd)), | |
| 770 | [WS_METHODS.gitCreateBranch]: (input) => | |
| 771 | observeRpcEffect( | |
| 772 | WS_METHODS.gitCreateBranch, | |
| 773 | git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 774 | { "rpc.aggregate": "git" }, | |
| 708 | 775 | ), |
| 709 | { "rpc.aggregate": "git" }, | |
| 710 | ), | |
| 711 | [WS_METHODS.gitInit]: (input) => | |
| 712 | observeRpcEffect( | |
| 713 | WS_METHODS.gitInit, | |
| 714 | git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 715 | { "rpc.aggregate": "git" }, | |
| 716 | ), | |
| 717 | [WS_METHODS.terminalOpen]: (input) => | |
| 718 | observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { | |
| 719 | "rpc.aggregate": "terminal", | |
| 720 | }), | |
| 721 | [WS_METHODS.terminalWrite]: (input) => | |
| 722 | observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { | |
| 723 | "rpc.aggregate": "terminal", | |
| 724 | }), | |
| 725 | [WS_METHODS.terminalResize]: (input) => | |
| 726 | observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { | |
| 727 | "rpc.aggregate": "terminal", | |
| 728 | }), | |
| 729 | [WS_METHODS.terminalClear]: (input) => | |
| 730 | observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { | |
| 731 | "rpc.aggregate": "terminal", | |
| 732 | }), | |
| 733 | [WS_METHODS.terminalRestart]: (input) => | |
| 734 | observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { | |
| 735 | "rpc.aggregate": "terminal", | |
| 736 | }), | |
| 737 | [WS_METHODS.terminalClose]: (input) => | |
| 738 | observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { | |
| 739 | "rpc.aggregate": "terminal", | |
| 740 | }), | |
| 741 | [WS_METHODS.subscribeTerminalEvents]: (_input) => | |
| 742 | observeRpcStream( | |
| 743 | WS_METHODS.subscribeTerminalEvents, | |
| 744 | Stream.callback<TerminalEvent>((queue) => | |
| 745 | Effect.acquireRelease( | |
| 746 | terminalManager.subscribe((event) => Queue.offer(queue, event)), | |
| 747 | (unsubscribe) => Effect.sync(unsubscribe), | |
| 776 | [WS_METHODS.gitCheckout]: (input) => | |
| 777 | observeRpcEffect( | |
| 778 | WS_METHODS.gitCheckout, | |
| 779 | Effect.scoped(git.checkoutBranch(input)).pipe( | |
| 780 | Effect.tap(() => refreshGitStatus(input.cwd)), | |
| 748 | 781 | ), |
| 782 | { "rpc.aggregate": "git" }, | |
| 749 | 783 | ), |
| 750 | { "rpc.aggregate": "terminal" }, | |
| 751 | ), | |
| 752 | [WS_METHODS.subscribeServerConfig]: (_input) => | |
| 753 | observeRpcStreamEffect( | |
| 754 | WS_METHODS.subscribeServerConfig, | |
| 755 | Effect.gen(function* () { | |
| 756 | const keybindingsUpdates = keybindings.streamChanges.pipe( | |
| 757 | Stream.map((event) => ({ | |
| 758 | version: 1 as const, | |
| 759 | type: "keybindingsUpdated" as const, | |
| 760 | payload: { | |
| 761 | issues: event.issues, | |
| 762 | }, | |
| 763 | })), | |
| 764 | ); | |
| 765 | const providerStatuses = providerRegistry.streamChanges.pipe( | |
| 766 | Stream.map((providers) => ({ | |
| 767 | version: 1 as const, | |
| 768 | type: "providerStatuses" as const, | |
| 769 | payload: { providers }, | |
| 770 | })), | |
| 771 | ); | |
| 772 | const settingsUpdates = serverSettings.streamChanges.pipe( | |
| 773 | Stream.map((settings) => ({ | |
| 774 | version: 1 as const, | |
| 775 | type: "settingsUpdated" as const, | |
| 776 | payload: { settings }, | |
| 777 | })), | |
| 778 | ); | |
| 779 | ||
| 780 | return Stream.concat( | |
| 781 | Stream.make({ | |
| 782 | version: 1 as const, | |
| 783 | type: "snapshot" as const, | |
| 784 | config: yield* loadServerConfig, | |
| 785 | }), | |
| 786 | Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), | |
| 787 | ); | |
| 784 | [WS_METHODS.gitInit]: (input) => | |
| 785 | observeRpcEffect( | |
| 786 | WS_METHODS.gitInit, | |
| 787 | git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | |
| 788 | { "rpc.aggregate": "git" }, | |
| 789 | ), | |
| 790 | [WS_METHODS.terminalOpen]: (input) => | |
| 791 | observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { | |
| 792 | "rpc.aggregate": "terminal", | |
| 788 | 793 | }), |
| 789 | { "rpc.aggregate": "server" }, | |
| 790 | ), | |
| 791 | [WS_METHODS.subscribeServerLifecycle]: (_input) => | |
| 792 | observeRpcStreamEffect( | |
| 793 | WS_METHODS.subscribeServerLifecycle, | |
| 794 | Effect.gen(function* () { | |
| 795 | const snapshot = yield* lifecycleEvents.snapshot; | |
| 796 | const snapshotEvents = Array.from(snapshot.events).toSorted( | |
| 797 | (left, right) => left.sequence - right.sequence, | |
| 798 | ); | |
| 799 | const liveEvents = lifecycleEvents.stream.pipe( | |
| 800 | Stream.filter((event) => event.sequence > snapshot.sequence), | |
| 801 | ); | |
| 802 | return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); | |
| 794 | [WS_METHODS.terminalWrite]: (input) => | |
| 795 | observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { | |
| 796 | "rpc.aggregate": "terminal", | |
| 803 | 797 | }), |
| 804 | { "rpc.aggregate": "server" }, | |
| 805 | ), | |
| 806 | }); | |
| 807 | }), | |
| 808 | ); | |
| 798 | [WS_METHODS.terminalResize]: (input) => | |
| 799 | observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { | |
| 800 | "rpc.aggregate": "terminal", | |
| 801 | }), | |
| 802 | [WS_METHODS.terminalClear]: (input) => | |
| 803 | observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { | |
| 804 | "rpc.aggregate": "terminal", | |
| 805 | }), | |
| 806 | [WS_METHODS.terminalRestart]: (input) => | |
| 807 | observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { | |
| 808 | "rpc.aggregate": "terminal", | |
| 809 | }), | |
| 810 | [WS_METHODS.terminalClose]: (input) => | |
| 811 | observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { | |
| 812 | "rpc.aggregate": "terminal", | |
| 813 | }), | |
| 814 | [WS_METHODS.subscribeTerminalEvents]: (_input) => | |
| 815 | observeRpcStream( | |
| 816 | WS_METHODS.subscribeTerminalEvents, | |
| 817 | Stream.callback<TerminalEvent>((queue) => | |
| 818 | Effect.acquireRelease( | |
| 819 | terminalManager.subscribe((event) => Queue.offer(queue, event)), | |
| 820 | (unsubscribe) => Effect.sync(unsubscribe), | |
| 821 | ), | |
| 822 | ), | |
| 823 | { "rpc.aggregate": "terminal" }, | |
| 824 | ), | |
| 825 | [WS_METHODS.subscribeServerConfig]: (_input) => | |
| 826 | observeRpcStreamEffect( | |
| 827 | WS_METHODS.subscribeServerConfig, | |
| 828 | Effect.gen(function* () { | |
| 829 | const keybindingsUpdates = keybindings.streamChanges.pipe( | |
| 830 | Stream.map((event) => ({ | |
| 831 | version: 1 as const, | |
| 832 | type: "keybindingsUpdated" as const, | |
| 833 | payload: { | |
| 834 | issues: event.issues, | |
| 835 | }, | |
| 836 | })), | |
| 837 | ); | |
| 838 | const providerStatuses = providerRegistry.streamChanges.pipe( | |
| 839 | Stream.map((providers) => ({ | |
| 840 | version: 1 as const, | |
| 841 | type: "providerStatuses" as const, | |
| 842 | payload: { providers }, | |
| 843 | })), | |
| 844 | ); | |
| 845 | const settingsUpdates = serverSettings.streamChanges.pipe( | |
| 846 | Stream.map((settings) => ({ | |
| 847 | version: 1 as const, | |
| 848 | type: "settingsUpdated" as const, | |
| 849 | payload: { settings }, | |
| 850 | })), | |
| 851 | ); | |
| 852 | ||
| 853 | return Stream.concat( | |
| 854 | Stream.make({ | |
| 855 | version: 1 as const, | |
| 856 | type: "snapshot" as const, | |
| 857 | config: yield* loadServerConfig, | |
| 858 | }), | |
| 859 | Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), | |
| 860 | ); | |
| 861 | }), | |
| 862 | { "rpc.aggregate": "server" }, | |
| 863 | ), | |
| 864 | [WS_METHODS.subscribeServerLifecycle]: (_input) => | |
| 865 | observeRpcStreamEffect( | |
| 866 | WS_METHODS.subscribeServerLifecycle, | |
| 867 | Effect.gen(function* () { | |
| 868 | const snapshot = yield* lifecycleEvents.snapshot; | |
| 869 | const snapshotEvents = Array.from(snapshot.events).toSorted( | |
| 870 | (left, right) => left.sequence - right.sequence, | |
| 871 | ); | |
| 872 | const liveEvents = lifecycleEvents.stream.pipe( | |
| 873 | Stream.filter((event) => event.sequence > snapshot.sequence), | |
| 874 | ); | |
| 875 | return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); | |
| 876 | }), | |
| 877 | { "rpc.aggregate": "server" }, | |
| 878 | ), | |
| 879 | [WS_METHODS.subscribeAuthAccess]: (_input) => | |
| 880 | observeRpcStreamEffect( | |
| 881 | WS_METHODS.subscribeAuthAccess, | |
| 882 | Effect.gen(function* () { | |
| 883 | const initialSnapshot = yield* loadAuthAccessSnapshot(); | |
| 884 | const revisionRef = yield* Ref.make(1); | |
| 885 | const accessChanges: Stream.Stream< | |
| 886 | BootstrapCredentialChange | SessionCredentialChange | |
| 887 | > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges); | |
| 888 | ||
| 889 | const liveEvents: Stream.Stream<AuthAccessStreamEvent> = accessChanges.pipe( | |
| 890 | Stream.mapEffect((change) => | |
| 891 | Ref.updateAndGet(revisionRef, (revision) => revision + 1).pipe( | |
| 892 | Effect.map((revision) => | |
| 893 | toAuthAccessStreamEvent(change, revision, currentSessionId), | |
| 894 | ), | |
| 895 | ), | |
| 896 | ), | |
| 897 | ); | |
| 898 | ||
| 899 | return Stream.concat( | |
| 900 | Stream.make({ | |
| 901 | version: 1 as const, | |
| 902 | revision: 1, | |
| 903 | type: "snapshot" as const, | |
| 904 | payload: initialSnapshot, | |
| 905 | }), | |
| 906 | liveEvents, | |
| 907 | ); | |
| 908 | }), | |
| 909 | { "rpc.aggregate": "auth" }, | |
| 910 | ), | |
| 911 | }); | |
| 912 | }), | |
| 913 | ); | |
| 809 | 914 | |
| 810 | 915 | export const websocketRpcRouteLayer = Layer.unwrap( |
| 811 | Effect.gen(function* () { | |
| 812 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { | |
| 813 | spanPrefix: "ws.rpc", | |
| 814 | spanAttributes: { | |
| 815 | "rpc.transport": "websocket", | |
| 816 | "rpc.system": "effect-rpc", | |
| 817 | }, | |
| 818 | }).pipe(Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson))); | |
| 819 | return HttpRouter.add( | |
| 916 | Effect.succeed( | |
| 917 | HttpRouter.add( | |
| 820 | 918 | "GET", |
| 821 | 919 | "/ws", |
| 822 | 920 | Effect.gen(function* () { |
| 823 | 921 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 824 | const config = yield* ServerConfig; | |
| 825 | if (config.authToken) { | |
| 826 | const url = HttpServerRequest.toURL(request); | |
| 827 | if (Option.isNone(url)) { | |
| 828 | return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 }); | |
| 829 | } | |
| 830 | const token = url.value.searchParams.get("token"); | |
| 831 | if (token !== config.authToken) { | |
| 832 | return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 }); | |
| 833 | } | |
| 834 | } | |
| 835 | return yield* rpcWebSocketHttpEffect; | |
| 836 | }), | |
| 837 | ); | |
| 838 | }), | |
| 922 | const serverAuth = yield* ServerAuth; | |
| 923 | const sessions = yield* SessionCredentialService; | |
| 924 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request); | |
| 925 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { | |
| 926 | spanPrefix: "ws.rpc", | |
| 927 | spanAttributes: { | |
| 928 | "rpc.transport": "websocket", | |
| 929 | "rpc.system": "effect-rpc", | |
| 930 | }, | |
| 931 | }).pipe( | |
| 932 | Effect.provide( | |
| 933 | makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)), | |
| 934 | ), | |
| 935 | ); | |
| 936 | return yield* Effect.acquireUseRelease( | |
| 937 | sessions.markConnected(session.sessionId), | |
| 938 | () => rpcWebSocketHttpEffect, | |
| 939 | () => sessions.markDisconnected(session.sessionId), | |
| 940 | ); | |
| 941 | }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 942 | ), | |
| 943 | ), | |
| 839 | 944 | ); |
apps/web/index.html+83-3| ... | ... | @@ -2,9 +2,84 @@ |
| 2 | 2 | <html lang="en"> |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="UTF-8" /> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> | |
| 6 | <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> | |
| 7 | <meta name="theme-color" content="#161616" media="(prefers-color-scheme: dark)" /> | |
| 8 | <meta name="theme-color" content="#161616" /> | |
| 6 | 9 | <link rel="icon" href="/favicon.ico" sizes="48x48" /> |
| 7 | 10 | <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> |
| 11 | <script> | |
| 12 | (() => { | |
| 13 | const LIGHT_BACKGROUND = "#ffffff"; | |
| 14 | const DARK_BACKGROUND = "#161616"; | |
| 15 | const themeColorMeta = document.querySelector('meta[name="theme-color"]'); | |
| 16 | try { | |
| 17 | const storedTheme = window.localStorage.getItem("t3code:theme"); | |
| 18 | const theme = | |
| 19 | storedTheme === "light" || storedTheme === "dark" || storedTheme === "system" | |
| 20 | ? storedTheme | |
| 21 | : "system"; | |
| 22 | const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; | |
| 23 | const isDark = theme === "dark" || (theme === "system" && prefersDark); | |
| 24 | document.documentElement.classList.toggle("dark", isDark); | |
| 25 | const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; | |
| 26 | document.documentElement.style.backgroundColor = chromeColor; | |
| 27 | themeColorMeta?.setAttribute("content", chromeColor); | |
| 28 | } catch { | |
| 29 | document.documentElement.classList.add("dark"); | |
| 30 | document.documentElement.style.backgroundColor = DARK_BACKGROUND; | |
| 31 | themeColorMeta?.setAttribute("content", DARK_BACKGROUND); | |
| 32 | } | |
| 33 | })(); | |
| 34 | </script> | |
| 35 | <style> | |
| 36 | html, | |
| 37 | body, | |
| 38 | #root { | |
| 39 | width: 100%; | |
| 40 | height: 100%; | |
| 41 | margin: 0; | |
| 42 | } | |
| 43 | ||
| 44 | body { | |
| 45 | background: #ffffff; | |
| 46 | color: #262626; | |
| 47 | font-family: | |
| 48 | "DM Sans", | |
| 49 | -apple-system, | |
| 50 | BlinkMacSystemFont, | |
| 51 | "Segoe UI", | |
| 52 | system-ui, | |
| 53 | sans-serif; | |
| 54 | } | |
| 55 | ||
| 56 | html.dark body { | |
| 57 | background: #161616; | |
| 58 | color: #f5f5f5; | |
| 59 | } | |
| 60 | ||
| 61 | #boot-shell { | |
| 62 | display: flex; | |
| 63 | min-height: 100%; | |
| 64 | align-items: center; | |
| 65 | justify-content: center; | |
| 66 | background: inherit; | |
| 67 | } | |
| 68 | ||
| 69 | #boot-shell-card { | |
| 70 | display: flex; | |
| 71 | align-items: center; | |
| 72 | justify-content: center; | |
| 73 | width: 96px; | |
| 74 | height: 96px; | |
| 75 | } | |
| 76 | ||
| 77 | #boot-shell-logo { | |
| 78 | width: 64px; | |
| 79 | height: 64px; | |
| 80 | object-fit: contain; | |
| 81 | } | |
| 82 | </style> | |
| 8 | 83 | <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| 9 | 84 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| 10 | 85 | <link |
| ... | ... | @@ -12,10 +87,15 @@ |
| 12 | 87 | rel="stylesheet" |
| 13 | 88 | /> |
| 14 | 89 | <title>T3 Code (Alpha)</title> |
| 15 | <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script> | |
| 16 | 90 | </head> |
| 17 | 91 | <body> |
| 18 | <div id="root"></div> | |
| 92 | <div id="root"> | |
| 93 | <div id="boot-shell"> | |
| 94 | <div id="boot-shell-card" aria-label="T3 Code splash screen"> | |
| 95 | <img id="boot-shell-logo" src="/apple-touch-icon.png" alt="T3 Code" /> | |
| 96 | </div> | |
| 97 | </div> | |
| 98 | </div> | |
| 19 | 99 | <script type="module" src="/src/main.tsx"></script> |
| 20 | 100 | </body> |
| 21 | 101 | </html> |
apps/web/src/authBootstrap.test.ts created+460| ... | ... | @@ -0,0 +1,460 @@ |
| 1 | import type { DesktopBridge } from "@t3tools/contracts"; | |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | ||
| 4 | function jsonResponse(body: unknown, init?: ResponseInit) { | |
| 5 | return new Response(JSON.stringify(body), { | |
| 6 | headers: { | |
| 7 | "content-type": "application/json", | |
| 8 | }, | |
| 9 | status: 200, | |
| 10 | ...init, | |
| 11 | }); | |
| 12 | } | |
| 13 | ||
| 14 | type TestWindow = { | |
| 15 | location: URL; | |
| 16 | history: { | |
| 17 | replaceState: (_data: unknown, _unused: string, url: string) => void; | |
| 18 | }; | |
| 19 | desktopBridge?: DesktopBridge; | |
| 20 | }; | |
| 21 | ||
| 22 | function installTestBrowser(url: string) { | |
| 23 | const testWindow: TestWindow = { | |
| 24 | location: new URL(url), | |
| 25 | history: { | |
| 26 | replaceState: (_data, _unused, nextUrl) => { | |
| 27 | testWindow.location = new URL(nextUrl, testWindow.location.href); | |
| 28 | }, | |
| 29 | }, | |
| 30 | }; | |
| 31 | ||
| 32 | vi.stubGlobal("window", testWindow); | |
| 33 | vi.stubGlobal("document", { title: "T3 Code" }); | |
| 34 | ||
| 35 | return testWindow; | |
| 36 | } | |
| 37 | ||
| 38 | function sessionResponse(body: unknown, init?: ResponseInit) { | |
| 39 | return jsonResponse(body, init); | |
| 40 | } | |
| 41 | ||
| 42 | describe("resolveInitialServerAuthGateState", () => { | |
| 43 | beforeEach(() => { | |
| 44 | vi.restoreAllMocks(); | |
| 45 | vi.useRealTimers(); | |
| 46 | installTestBrowser("http://localhost/"); | |
| 47 | }); | |
| 48 | ||
| 49 | afterEach(async () => { | |
| 50 | const { __resetServerAuthBootstrapForTests } = await import("./environments/primary"); | |
| 51 | __resetServerAuthBootstrapForTests(); | |
| 52 | vi.unstubAllEnvs(); | |
| 53 | vi.useRealTimers(); | |
| 54 | vi.restoreAllMocks(); | |
| 55 | }); | |
| 56 | ||
| 57 | it("reuses an in-flight silent bootstrap attempt", async () => { | |
| 58 | const fetchMock = vi | |
| 59 | .fn<typeof fetch>() | |
| 60 | .mockResolvedValueOnce( | |
| 61 | sessionResponse({ | |
| 62 | authenticated: false, | |
| 63 | auth: { | |
| 64 | policy: "desktop-managed-local", | |
| 65 | bootstrapMethods: ["desktop-bootstrap"], | |
| 66 | sessionMethods: ["browser-session-cookie"], | |
| 67 | sessionCookieName: "t3_session", | |
| 68 | }, | |
| 69 | }), | |
| 70 | ) | |
| 71 | .mockResolvedValueOnce( | |
| 72 | jsonResponse({ | |
| 73 | authenticated: true, | |
| 74 | sessionMethod: "browser-session-cookie", | |
| 75 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 76 | }), | |
| 77 | ) | |
| 78 | .mockResolvedValueOnce( | |
| 79 | sessionResponse({ | |
| 80 | authenticated: true, | |
| 81 | auth: { | |
| 82 | policy: "loopback-browser", | |
| 83 | bootstrapMethods: ["one-time-token"], | |
| 84 | sessionMethods: ["browser-session-cookie"], | |
| 85 | sessionCookieName: "t3_session", | |
| 86 | }, | |
| 87 | sessionMethod: "browser-session-cookie", | |
| 88 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 89 | }), | |
| 90 | ); | |
| 91 | vi.stubGlobal("fetch", fetchMock); | |
| 92 | ||
| 93 | const testWindow = installTestBrowser("http://localhost/"); | |
| 94 | testWindow.desktopBridge = { | |
| 95 | getLocalEnvironmentBootstrap: () => ({ | |
| 96 | label: "Local environment", | |
| 97 | httpBaseUrl: "http://localhost:3773", | |
| 98 | wsBaseUrl: "ws://localhost:3773", | |
| 99 | bootstrapToken: "desktop-bootstrap-token", | |
| 100 | }), | |
| 101 | } as DesktopBridge; | |
| 102 | ||
| 103 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | |
| 104 | ||
| 105 | await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); | |
| 106 | ||
| 107 | expect(fetchMock).toHaveBeenCalledTimes(3); | |
| 108 | expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3773/api/auth/session"); | |
| 109 | expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3773/api/auth/bootstrap"); | |
| 110 | expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session"); | |
| 111 | }); | |
| 112 | ||
| 113 | it("uses https fetch urls when the primary environment uses wss", async () => { | |
| 114 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | |
| 115 | sessionResponse({ | |
| 116 | authenticated: false, | |
| 117 | auth: { | |
| 118 | policy: "loopback-browser", | |
| 119 | bootstrapMethods: ["one-time-token"], | |
| 120 | sessionMethods: ["browser-session-cookie"], | |
| 121 | sessionCookieName: "t3_session", | |
| 122 | }, | |
| 123 | }), | |
| 124 | ); | |
| 125 | vi.stubGlobal("fetch", fetchMock); | |
| 126 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); | |
| 127 | vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); | |
| 128 | ||
| 129 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | |
| 130 | ||
| 131 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | |
| 132 | status: "requires-auth", | |
| 133 | auth: { | |
| 134 | policy: "loopback-browser", | |
| 135 | bootstrapMethods: ["one-time-token"], | |
| 136 | sessionMethods: ["browser-session-cookie"], | |
| 137 | sessionCookieName: "t3_session", | |
| 138 | }, | |
| 139 | }); | |
| 140 | ||
| 141 | expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/session", { | |
| 142 | credentials: "include", | |
| 143 | }); | |
| 144 | }); | |
| 145 | ||
| 146 | it("uses the current origin as an auth proxy base for local dev environments", async () => { | |
| 147 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | |
| 148 | sessionResponse({ | |
| 149 | authenticated: false, | |
| 150 | auth: { | |
| 151 | policy: "loopback-browser", | |
| 152 | bootstrapMethods: ["one-time-token"], | |
| 153 | sessionMethods: ["browser-session-cookie"], | |
| 154 | sessionCookieName: "t3_session", | |
| 155 | }, | |
| 156 | }), | |
| 157 | ); | |
| 158 | vi.stubGlobal("fetch", fetchMock); | |
| 159 | installTestBrowser("http://localhost:5735/"); | |
| 160 | ||
| 161 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | |
| 162 | ||
| 163 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | |
| 164 | status: "requires-auth", | |
| 165 | auth: { | |
| 166 | policy: "loopback-browser", | |
| 167 | bootstrapMethods: ["one-time-token"], | |
| 168 | sessionMethods: ["browser-session-cookie"], | |
| 169 | sessionCookieName: "t3_session", | |
| 170 | }, | |
| 171 | }); | |
| 172 | ||
| 173 | expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/api/auth/session", { | |
| 174 | credentials: "include", | |
| 175 | }); | |
| 176 | }); | |
| 177 | ||
| 178 | it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { | |
| 179 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | |
| 180 | sessionResponse({ | |
| 181 | authenticated: false, | |
| 182 | auth: { | |
| 183 | policy: "loopback-browser", | |
| 184 | bootstrapMethods: ["one-time-token"], | |
| 185 | sessionMethods: ["browser-session-cookie"], | |
| 186 | sessionCookieName: "t3_session", | |
| 187 | }, | |
| 188 | }), | |
| 189 | ); | |
| 190 | vi.stubGlobal("fetch", fetchMock); | |
| 191 | ||
| 192 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | |
| 193 | ||
| 194 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | |
| 195 | status: "requires-auth", | |
| 196 | auth: { | |
| 197 | policy: "loopback-browser", | |
| 198 | bootstrapMethods: ["one-time-token"], | |
| 199 | sessionMethods: ["browser-session-cookie"], | |
| 200 | sessionCookieName: "t3_session", | |
| 201 | }, | |
| 202 | }); | |
| 203 | }); | |
| 204 | ||
| 205 | it("retries transient auth session bootstrap failures after restart", async () => { | |
| 206 | vi.useFakeTimers(); | |
| 207 | const fetchMock = vi | |
| 208 | .fn<typeof fetch>() | |
| 209 | .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) | |
| 210 | .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) | |
| 211 | .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) | |
| 212 | .mockResolvedValueOnce( | |
| 213 | sessionResponse({ | |
| 214 | authenticated: false, | |
| 215 | auth: { | |
| 216 | policy: "loopback-browser", | |
| 217 | bootstrapMethods: ["one-time-token"], | |
| 218 | sessionMethods: ["browser-session-cookie"], | |
| 219 | sessionCookieName: "t3_session", | |
| 220 | }, | |
| 221 | }), | |
| 222 | ); | |
| 223 | vi.stubGlobal("fetch", fetchMock); | |
| 224 | ||
| 225 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | |
| 226 | ||
| 227 | const gateStatePromise = resolveInitialServerAuthGateState(); | |
| 228 | await vi.advanceTimersByTimeAsync(2_000); | |
| 229 | ||
| 230 | await expect(gateStatePromise).resolves.toEqual({ | |
| 231 | status: "requires-auth", | |
| 232 | auth: { | |
| 233 | policy: "loopback-browser", | |
| 234 | bootstrapMethods: ["one-time-token"], | |
| 235 | sessionMethods: ["browser-session-cookie"], | |
| 236 | sessionCookieName: "t3_session", | |
| 237 | }, | |
| 238 | }); | |
| 239 | expect(fetchMock).toHaveBeenCalledTimes(4); | |
| 240 | }); | |
| 241 | ||
| 242 | it("takes a pairing token from the location hash and strips it immediately", async () => { | |
| 243 | const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); | |
| 244 | const { takePairingTokenFromUrl } = await import("./environments/primary"); | |
| 245 | ||
| 246 | expect(takePairingTokenFromUrl()).toBe("pairing-token"); | |
| 247 | expect(testWindow.location.hash).toBe(""); | |
| 248 | expect(testWindow.location.searchParams.get("token")).toBeNull(); | |
| 249 | }); | |
| 250 | ||
| 251 | it("accepts query-string pairing tokens as a backward-compatible fallback", async () => { | |
| 252 | const testWindow = installTestBrowser("http://localhost/?token=pairing-token"); | |
| 253 | const { takePairingTokenFromUrl } = await import("./environments/primary"); | |
| 254 | ||
| 255 | expect(takePairingTokenFromUrl()).toBe("pairing-token"); | |
| 256 | expect(testWindow.location.searchParams.get("token")).toBeNull(); | |
| 257 | }); | |
| 258 | ||
| 259 | it("allows manual token submission after the initial auth check requires pairing", async () => { | |
| 260 | const fetchMock = vi | |
| 261 | .fn<typeof fetch>() | |
| 262 | .mockResolvedValueOnce( | |
| 263 | sessionResponse({ | |
| 264 | authenticated: false, | |
| 265 | auth: { | |
| 266 | policy: "loopback-browser", | |
| 267 | bootstrapMethods: ["one-time-token"], | |
| 268 | sessionMethods: ["browser-session-cookie"], | |
| 269 | sessionCookieName: "t3_session", | |
| 270 | }, | |
| 271 | }), | |
| 272 | ) | |
| 273 | .mockResolvedValueOnce( | |
| 274 | jsonResponse({ | |
| 275 | authenticated: true, | |
| 276 | sessionMethod: "browser-session-cookie", | |
| 277 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 278 | }), | |
| 279 | ) | |
| 280 | .mockResolvedValueOnce( | |
| 281 | sessionResponse({ | |
| 282 | authenticated: true, | |
| 283 | auth: { | |
| 284 | policy: "loopback-browser", | |
| 285 | bootstrapMethods: ["one-time-token"], | |
| 286 | sessionMethods: ["browser-session-cookie"], | |
| 287 | sessionCookieName: "t3_session", | |
| 288 | }, | |
| 289 | sessionMethod: "browser-session-cookie", | |
| 290 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 291 | }), | |
| 292 | ); | |
| 293 | vi.stubGlobal("fetch", fetchMock); | |
| 294 | installTestBrowser("http://localhost/"); | |
| 295 | ||
| 296 | const { resolveInitialServerAuthGateState, submitServerAuthCredential } = | |
| 297 | await import("./environments/primary"); | |
| 298 | ||
| 299 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | |
| 300 | status: "requires-auth", | |
| 301 | auth: { | |
| 302 | policy: "loopback-browser", | |
| 303 | bootstrapMethods: ["one-time-token"], | |
| 304 | sessionMethods: ["browser-session-cookie"], | |
| 305 | sessionCookieName: "t3_session", | |
| 306 | }, | |
| 307 | }); | |
| 308 | await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); | |
| 309 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | |
| 310 | status: "authenticated", | |
| 311 | }); | |
| 312 | expect(fetchMock).toHaveBeenCalledTimes(3); | |
| 313 | }); | |
| 314 | ||
| 315 | it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { | |
| 316 | vi.useFakeTimers(); | |
| 317 | const fetchMock = vi | |
| 318 | .fn<typeof fetch>() | |
| 319 | .mockResolvedValueOnce( | |
| 320 | sessionResponse({ | |
| 321 | authenticated: false, | |
| 322 | auth: { | |
| 323 | policy: "desktop-managed-local", | |
| 324 | bootstrapMethods: ["desktop-bootstrap"], | |
| 325 | sessionMethods: ["browser-session-cookie"], | |
| 326 | sessionCookieName: "t3_session", | |
| 327 | }, | |
| 328 | }), | |
| 329 | ) | |
| 330 | .mockResolvedValueOnce( | |
| 331 | jsonResponse({ | |
| 332 | authenticated: true, | |
| 333 | sessionMethod: "browser-session-cookie", | |
| 334 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 335 | }), | |
| 336 | ) | |
| 337 | .mockResolvedValueOnce( | |
| 338 | sessionResponse({ | |
| 339 | authenticated: false, | |
| 340 | auth: { | |
| 341 | policy: "desktop-managed-local", | |
| 342 | bootstrapMethods: ["desktop-bootstrap"], | |
| 343 | sessionMethods: ["browser-session-cookie"], | |
| 344 | sessionCookieName: "t3_session", | |
| 345 | }, | |
| 346 | }), | |
| 347 | ) | |
| 348 | .mockResolvedValueOnce( | |
| 349 | sessionResponse({ | |
| 350 | authenticated: true, | |
| 351 | auth: { | |
| 352 | policy: "desktop-managed-local", | |
| 353 | bootstrapMethods: ["desktop-bootstrap"], | |
| 354 | sessionMethods: ["browser-session-cookie"], | |
| 355 | sessionCookieName: "t3_session", | |
| 356 | }, | |
| 357 | sessionMethod: "browser-session-cookie", | |
| 358 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 359 | }), | |
| 360 | ); | |
| 361 | vi.stubGlobal("fetch", fetchMock); | |
| 362 | ||
| 363 | const testWindow = installTestBrowser("http://localhost/"); | |
| 364 | testWindow.desktopBridge = { | |
| 365 | getLocalEnvironmentBootstrap: () => ({ | |
| 366 | label: "Local environment", | |
| 367 | httpBaseUrl: "http://localhost:3773", | |
| 368 | wsBaseUrl: "ws://localhost:3773", | |
| 369 | bootstrapToken: "desktop-bootstrap-token", | |
| 370 | }), | |
| 371 | } as DesktopBridge; | |
| 372 | ||
| 373 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | |
| 374 | ||
| 375 | const gateStatePromise = resolveInitialServerAuthGateState(); | |
| 376 | await vi.advanceTimersByTimeAsync(100); | |
| 377 | ||
| 378 | await expect(gateStatePromise).resolves.toEqual({ | |
| 379 | status: "authenticated", | |
| 380 | }); | |
| 381 | expect(fetchMock).toHaveBeenCalledTimes(4); | |
| 382 | expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session"); | |
| 383 | expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session"); | |
| 384 | }); | |
| 385 | ||
| 386 | it("revalidates the server session state after a previous authenticated result", async () => { | |
| 387 | const fetchMock = vi | |
| 388 | .fn<typeof fetch>() | |
| 389 | .mockResolvedValueOnce( | |
| 390 | sessionResponse({ | |
| 391 | authenticated: true, | |
| 392 | auth: { | |
| 393 | policy: "loopback-browser", | |
| 394 | bootstrapMethods: ["one-time-token"], | |
| 395 | sessionMethods: ["browser-session-cookie"], | |
| 396 | sessionCookieName: "t3_session", | |
| 397 | }, | |
| 398 | sessionMethod: "browser-session-cookie", | |
| 399 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 400 | }), | |
| 401 | ) | |
| 402 | .mockResolvedValueOnce( | |
| 403 | sessionResponse({ | |
| 404 | authenticated: false, | |
| 405 | auth: { | |
| 406 | policy: "loopback-browser", | |
| 407 | bootstrapMethods: ["one-time-token"], | |
| 408 | sessionMethods: ["browser-session-cookie"], | |
| 409 | sessionCookieName: "t3_session", | |
| 410 | }, | |
| 411 | }), | |
| 412 | ); | |
| 413 | vi.stubGlobal("fetch", fetchMock); | |
| 414 | ||
| 415 | const { resolveInitialServerAuthGateState } = await import("./environments/primary"); | |
| 416 | ||
| 417 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | |
| 418 | status: "authenticated", | |
| 419 | }); | |
| 420 | await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ | |
| 421 | status: "requires-auth", | |
| 422 | auth: { | |
| 423 | policy: "loopback-browser", | |
| 424 | bootstrapMethods: ["one-time-token"], | |
| 425 | sessionMethods: ["browser-session-cookie"], | |
| 426 | sessionCookieName: "t3_session", | |
| 427 | }, | |
| 428 | }); | |
| 429 | expect(fetchMock).toHaveBeenCalledTimes(2); | |
| 430 | }); | |
| 431 | ||
| 432 | it("creates a pairing credential from the authenticated auth endpoint", async () => { | |
| 433 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce( | |
| 434 | jsonResponse({ | |
| 435 | id: "pairing-link-1", | |
| 436 | credential: "pairing-token", | |
| 437 | label: "Julius iPhone", | |
| 438 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 439 | }), | |
| 440 | ); | |
| 441 | vi.stubGlobal("fetch", fetchMock); | |
| 442 | ||
| 443 | const { createServerPairingCredential } = await import("./environments/primary"); | |
| 444 | ||
| 445 | await expect(createServerPairingCredential("Julius iPhone")).resolves.toEqual({ | |
| 446 | id: "pairing-link-1", | |
| 447 | credential: "pairing-token", | |
| 448 | label: "Julius iPhone", | |
| 449 | expiresAt: "2026-04-05T00:00:00.000Z", | |
| 450 | }); | |
| 451 | expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/pairing-token", { | |
| 452 | body: JSON.stringify({ label: "Julius iPhone" }), | |
| 453 | credentials: "include", | |
| 454 | headers: { | |
| 455 | "content-type": "application/json", | |
| 456 | }, | |
| 457 | method: "POST", | |
| 458 | }); | |
| 459 | }); | |
| 460 | }); |
apps/web/src/components/BranchToolbar.logic.ts+8-1| ... | ... | @@ -1,10 +1,17 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, GitBranch, ProjectId } from "@t3tools/contracts"; | |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | export { |
| 4 | 4 | dedupeRemoteBranchesWithLocalMatches, |
| 5 | 5 | deriveLocalBranchNameFromRemoteRef, |
| 6 | 6 | } from "@t3tools/shared/git"; |
| 7 | 7 | |
| 8 | export interface EnvironmentOption { | |
| 9 | environmentId: EnvironmentId; | |
| 10 | projectId: ProjectId; | |
| 11 | label: string; | |
| 12 | isPrimary: boolean; | |
| 13 | } | |
| 14 | ||
| 8 | 15 | export const EnvMode = Schema.Literals(["local", "worktree"]); |
| 9 | 16 | export type EnvMode = typeof EnvMode.Type; |
| 10 | 17 |
apps/web/src/components/BranchToolbar.tsx+37-127| ... | ... | @@ -1,25 +1,19 @@ |
| 1 | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { FolderIcon, GitForkIcon } from "lucide-react"; | |
| 4 | import { useCallback, useMemo } from "react"; | |
| 3 | import { useMemo } from "react"; | |
| 5 | 4 | |
| 6 | 5 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 7 | import { readEnvironmentApi } from "../environmentApi"; | |
| 8 | import { newCommandId } from "../lib/utils"; | |
| 9 | 6 | import { useStore } from "../store"; |
| 10 | 7 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; |
| 11 | 8 | import { |
| 12 | EnvMode, | |
| 13 | resolveDraftEnvModeAfterBranchChange, | |
| 9 | type EnvMode, | |
| 10 | type EnvironmentOption, | |
| 14 | 11 | resolveEffectiveEnvMode, |
| 15 | 12 | } from "./BranchToolbar.logic"; |
| 16 | 13 | import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; |
| 17 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | |
| 18 | ||
| 19 | const envModeItems = [ | |
| 20 | { value: "local", label: "Local" }, | |
| 21 | { value: "worktree", label: "New worktree" }, | |
| 22 | ] as const; | |
| 14 | import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; | |
| 15 | import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; | |
| 16 | import { Separator } from "./ui/separator"; | |
| 23 | 17 | |
| 24 | 18 | interface BranchToolbarProps { |
| 25 | 19 | environmentId: EnvironmentId; |
| ... | ... | @@ -29,9 +23,11 @@ interface BranchToolbarProps { |
| 29 | 23 | envLocked: boolean; |
| 30 | 24 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| 31 | 25 | onComposerFocusRequest?: () => void; |
| 26 | availableEnvironments?: readonly EnvironmentOption[]; | |
| 27 | onEnvironmentChange?: (environmentId: EnvironmentId) => void; | |
| 32 | 28 | } |
| 33 | 29 | |
| 34 | export default function BranchToolbar({ | |
| 30 | export function BranchToolbar({ | |
| 35 | 31 | environmentId, |
| 36 | 32 | threadId, |
| 37 | 33 | draftId, |
| ... | ... | @@ -39,6 +35,8 @@ export default function BranchToolbar({ |
| 39 | 35 | envLocked, |
| 40 | 36 | onCheckoutPullRequestRequest, |
| 41 | 37 | onComposerFocusRequest, |
| 38 | availableEnvironments, | |
| 39 | onEnvironmentChange, | |
| 42 | 40 | }: BranchToolbarProps) { |
| 43 | 41 | const threadRef = useMemo( |
| 44 | 42 | () => scopeThreadRef(environmentId, threadId), |
| ... | ... | @@ -46,10 +44,7 @@ export default function BranchToolbar({ |
| 46 | 44 | ); |
| 47 | 45 | const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); |
| 48 | 46 | const serverThread = useStore(serverThreadSelector); |
| 49 | const serverSession = serverThread?.session ?? null; | |
| 50 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); | |
| 51 | 47 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); |
| 52 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | |
| 53 | 48 | const activeProjectRef = serverThread |
| 54 | 49 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) |
| 55 | 50 | : draftThread |
| ... | ... | @@ -60,131 +55,46 @@ export default function BranchToolbar({ |
| 60 | 55 | [activeProjectRef], |
| 61 | 56 | ); |
| 62 | 57 | const activeProject = useStore(activeProjectSelector); |
| 63 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); | |
| 64 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; | |
| 58 | const hasActiveThread = serverThread !== undefined || draftThread !== null; | |
| 65 | 59 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| 66 | const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null; | |
| 67 | const hasServerThread = serverThread !== undefined; | |
| 68 | 60 | const effectiveEnvMode = resolveEffectiveEnvMode({ |
| 69 | 61 | activeWorktreePath, |
| 70 | hasServerThread, | |
| 62 | hasServerThread: serverThread !== undefined, | |
| 71 | 63 | draftThreadEnvMode: draftThread?.envMode, |
| 72 | 64 | }); |
| 73 | 65 | |
| 74 | const setThreadBranch = useCallback( | |
| 75 | (branch: string | null, worktreePath: string | null) => { | |
| 76 | if (!activeThreadId || !activeProject) return; | |
| 77 | const api = readEnvironmentApi(environmentId); | |
| 78 | // If the effective cwd is about to change, stop the running session so the | |
| 79 | // next message creates a new one with the correct cwd. | |
| 80 | if (serverSession && worktreePath !== activeWorktreePath && api) { | |
| 81 | void api.orchestration | |
| 82 | .dispatchCommand({ | |
| 83 | type: "thread.session.stop", | |
| 84 | commandId: newCommandId(), | |
| 85 | threadId: activeThreadId, | |
| 86 | createdAt: new Date().toISOString(), | |
| 87 | }) | |
| 88 | .catch(() => undefined); | |
| 89 | } | |
| 90 | if (api && hasServerThread) { | |
| 91 | void api.orchestration.dispatchCommand({ | |
| 92 | type: "thread.meta.update", | |
| 93 | commandId: newCommandId(), | |
| 94 | threadId: activeThreadId, | |
| 95 | branch, | |
| 96 | worktreePath, | |
| 97 | }); | |
| 98 | } | |
| 99 | if (hasServerThread) { | |
| 100 | setThreadBranchAction(activeThreadId, branch, worktreePath); | |
| 101 | return; | |
| 102 | } | |
| 103 | const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ | |
| 104 | nextWorktreePath: worktreePath, | |
| 105 | currentWorktreePath: activeWorktreePath, | |
| 106 | effectiveEnvMode, | |
| 107 | }); | |
| 108 | setDraftThreadContext(draftId ?? threadRef, { | |
| 109 | branch, | |
| 110 | worktreePath, | |
| 111 | envMode: nextDraftEnvMode, | |
| 112 | projectRef: scopeProjectRef(environmentId, activeProject.id), | |
| 113 | }); | |
| 114 | }, | |
| 115 | [ | |
| 116 | activeThreadId, | |
| 117 | activeProject, | |
| 118 | serverSession, | |
| 119 | activeWorktreePath, | |
| 120 | hasServerThread, | |
| 121 | setThreadBranchAction, | |
| 122 | setDraftThreadContext, | |
| 123 | draftId, | |
| 124 | threadRef, | |
| 125 | environmentId, | |
| 126 | effectiveEnvMode, | |
| 127 | ], | |
| 128 | ); | |
| 66 | const showEnvironmentPicker = | |
| 67 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange; | |
| 129 | 68 | |
| 130 | if (!activeThreadId || !activeProject) return null; | |
| 69 | if (!hasActiveThread || !activeProject) return null; | |
| 131 | 70 | |
| 132 | 71 | return ( |
| 133 | 72 | <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1"> |
| 134 | {envLocked || activeWorktreePath ? ( | |
| 135 | <span className="inline-flex items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs"> | |
| 136 | {activeWorktreePath ? ( | |
| 137 | <> | |
| 138 | <GitForkIcon className="size-3" /> | |
| 139 | Worktree | |
| 140 | </> | |
| 141 | ) : ( | |
| 142 | <> | |
| 143 | <FolderIcon className="size-3" /> | |
| 144 | Local | |
| 145 | </> | |
| 146 | )} | |
| 147 | </span> | |
| 148 | ) : ( | |
| 149 | <Select | |
| 150 | value={effectiveEnvMode} | |
| 151 | onValueChange={(value) => onEnvModeChange(value as EnvMode)} | |
| 152 | items={envModeItems} | |
| 153 | > | |
| 154 | <SelectTrigger variant="ghost" size="xs" className="font-medium"> | |
| 155 | {effectiveEnvMode === "worktree" ? ( | |
| 156 | <GitForkIcon className="size-3" /> | |
| 157 | ) : ( | |
| 158 | <FolderIcon className="size-3" /> | |
| 159 | )} | |
| 160 | <SelectValue /> | |
| 161 | </SelectTrigger> | |
| 162 | <SelectPopup> | |
| 163 | <SelectItem value="local"> | |
| 164 | <span className="inline-flex items-center gap-1.5"> | |
| 165 | <FolderIcon className="size-3" /> | |
| 166 | Local | |
| 167 | </span> | |
| 168 | </SelectItem> | |
| 169 | <SelectItem value="worktree"> | |
| 170 | <span className="inline-flex items-center gap-1.5"> | |
| 171 | <GitForkIcon className="size-3" /> | |
| 172 | New worktree | |
| 173 | </span> | |
| 174 | </SelectItem> | |
| 175 | </SelectPopup> | |
| 176 | </Select> | |
| 177 | )} | |
| 73 | <div className="flex items-center gap-1"> | |
| 74 | {showEnvironmentPicker && ( | |
| 75 | <> | |
| 76 | <BranchToolbarEnvironmentSelector | |
| 77 | envLocked={envLocked} | |
| 78 | environmentId={environmentId} | |
| 79 | availableEnvironments={availableEnvironments} | |
| 80 | onEnvironmentChange={onEnvironmentChange} | |
| 81 | /> | |
| 82 | <Separator orientation="vertical" className="mx-0.5 h-3.5!" /> | |
| 83 | </> | |
| 84 | )} | |
| 85 | <BranchToolbarEnvModeSelector | |
| 86 | envLocked={envLocked} | |
| 87 | effectiveEnvMode={effectiveEnvMode} | |
| 88 | activeWorktreePath={activeWorktreePath} | |
| 89 | onEnvModeChange={onEnvModeChange} | |
| 90 | /> | |
| 91 | </div> | |
| 178 | 92 | |
| 179 | 93 | <BranchToolbarBranchSelector |
| 180 | 94 | environmentId={environmentId} |
| 181 | activeProjectCwd={activeProject.cwd} | |
| 182 | activeThreadBranch={activeThreadBranch} | |
| 183 | activeWorktreePath={activeWorktreePath} | |
| 184 | branchCwd={branchCwd} | |
| 185 | effectiveEnvMode={effectiveEnvMode} | |
| 95 | threadId={threadId} | |
| 96 | {...(draftId ? { draftId } : {})} | |
| 186 | 97 | envLocked={envLocked} |
| 187 | onSetThreadBranch={setThreadBranch} | |
| 188 | 98 | {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} |
| 189 | 99 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} |
| 190 | 100 | /> |
apps/web/src/components/BranchToolbarBranchSelector.tsx+127-33| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { EnvironmentId, GitBranch } from "@t3tools/contracts"; | |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | |
| 2 | import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; | |
| 2 | 3 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | 4 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | 5 | import { ChevronDownIcon } from "lucide-react"; |
| ... | ... | @@ -14,15 +15,20 @@ import { |
| 14 | 15 | useTransition, |
| 15 | 16 | } from "react"; |
| 16 | 17 | |
| 18 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | |
| 19 | import { readEnvironmentApi } from "../environmentApi"; | |
| 17 | 20 | import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; |
| 18 | 21 | import { useGitStatus } from "../lib/gitStatusState"; |
| 19 | import { readEnvironmentApi } from "../environmentApi"; | |
| 22 | import { newCommandId } from "../lib/utils"; | |
| 20 | 23 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 24 | import { useStore } from "../store"; | |
| 25 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | |
| 21 | 26 | import { |
| 22 | 27 | deriveLocalBranchNameFromRemoteRef, |
| 23 | EnvMode, | |
| 24 | 28 | resolveBranchSelectionTarget, |
| 25 | 29 | resolveBranchToolbarValue, |
| 30 | resolveDraftEnvModeAfterBranchChange, | |
| 31 | resolveEffectiveEnvMode, | |
| 26 | 32 | shouldIncludeBranchPickerItem, |
| 27 | 33 | } from "./BranchToolbar.logic"; |
| 28 | 34 | import { Button } from "./ui/button"; |
| ... | ... | @@ -40,13 +46,9 @@ import { toastManager } from "./ui/toast"; |
| 40 | 46 | |
| 41 | 47 | interface BranchToolbarBranchSelectorProps { |
| 42 | 48 | environmentId: EnvironmentId; |
| 43 | activeProjectCwd: string; | |
| 44 | activeThreadBranch: string | null; | |
| 45 | activeWorktreePath: string | null; | |
| 46 | branchCwd: string | null; | |
| 47 | effectiveEnvMode: EnvMode; | |
| 49 | threadId: ThreadId; | |
| 50 | draftId?: DraftId; | |
| 48 | 51 | envLocked: boolean; |
| 49 | onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; | |
| 50 | 52 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| 51 | 53 | onComposerFocusRequest?: () => void; |
| 52 | 54 | } |
| ... | ... | @@ -57,7 +59,7 @@ function toBranchActionErrorMessage(error: unknown): string { |
| 57 | 59 | |
| 58 | 60 | function getBranchTriggerLabel(input: { |
| 59 | 61 | activeWorktreePath: string | null; |
| 60 | effectiveEnvMode: EnvMode; | |
| 62 | effectiveEnvMode: "local" | "worktree"; | |
| 61 | 63 | resolvedActiveBranch: string | null; |
| 62 | 64 | }): string { |
| 63 | 65 | const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; |
| ... | ... | @@ -72,16 +74,109 @@ function getBranchTriggerLabel(input: { |
| 72 | 74 | |
| 73 | 75 | export function BranchToolbarBranchSelector({ |
| 74 | 76 | environmentId, |
| 75 | activeProjectCwd, | |
| 76 | activeThreadBranch, | |
| 77 | activeWorktreePath, | |
| 78 | branchCwd, | |
| 79 | effectiveEnvMode, | |
| 77 | threadId, | |
| 78 | draftId, | |
| 80 | 79 | envLocked, |
| 81 | onSetThreadBranch, | |
| 82 | 80 | onCheckoutPullRequestRequest, |
| 83 | 81 | onComposerFocusRequest, |
| 84 | 82 | }: BranchToolbarBranchSelectorProps) { |
| 83 | // --------------------------------------------------------------------------- | |
| 84 | // Thread / project state (pushed down from parent to colocate with mutation) | |
| 85 | // --------------------------------------------------------------------------- | |
| 86 | const threadRef = useMemo( | |
| 87 | () => scopeThreadRef(environmentId, threadId), | |
| 88 | [environmentId, threadId], | |
| 89 | ); | |
| 90 | const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); | |
| 91 | const serverThread = useStore(serverThreadSelector); | |
| 92 | const serverSession = serverThread?.session ?? null; | |
| 93 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); | |
| 94 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); | |
| 95 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | |
| 96 | ||
| 97 | const activeProjectRef = serverThread | |
| 98 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) | |
| 99 | : draftThread | |
| 100 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | |
| 101 | : null; | |
| 102 | const activeProjectSelector = useMemo( | |
| 103 | () => createProjectSelectorByRef(activeProjectRef), | |
| 104 | [activeProjectRef], | |
| 105 | ); | |
| 106 | const activeProject = useStore(activeProjectSelector); | |
| 107 | ||
| 108 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); | |
| 109 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; | |
| 110 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | |
| 111 | const activeProjectCwd = activeProject?.cwd ?? null; | |
| 112 | const branchCwd = activeWorktreePath ?? activeProjectCwd; | |
| 113 | const hasServerThread = serverThread !== undefined; | |
| 114 | const effectiveEnvMode = resolveEffectiveEnvMode({ | |
| 115 | activeWorktreePath, | |
| 116 | hasServerThread, | |
| 117 | draftThreadEnvMode: draftThread?.envMode, | |
| 118 | }); | |
| 119 | ||
| 120 | // --------------------------------------------------------------------------- | |
| 121 | // Thread branch mutation (colocated — only this component calls it) | |
| 122 | // --------------------------------------------------------------------------- | |
| 123 | const setThreadBranch = useCallback( | |
| 124 | (branch: string | null, worktreePath: string | null) => { | |
| 125 | if (!activeThreadId || !activeProject) return; | |
| 126 | const api = readEnvironmentApi(environmentId); | |
| 127 | if (serverSession && worktreePath !== activeWorktreePath && api) { | |
| 128 | void api.orchestration | |
| 129 | .dispatchCommand({ | |
| 130 | type: "thread.session.stop", | |
| 131 | commandId: newCommandId(), | |
| 132 | threadId: activeThreadId, | |
| 133 | createdAt: new Date().toISOString(), | |
| 134 | }) | |
| 135 | .catch(() => undefined); | |
| 136 | } | |
| 137 | if (api && hasServerThread) { | |
| 138 | void api.orchestration.dispatchCommand({ | |
| 139 | type: "thread.meta.update", | |
| 140 | commandId: newCommandId(), | |
| 141 | threadId: activeThreadId, | |
| 142 | branch, | |
| 143 | worktreePath, | |
| 144 | }); | |
| 145 | } | |
| 146 | if (hasServerThread) { | |
| 147 | setThreadBranchAction(activeThreadId, branch, worktreePath); | |
| 148 | return; | |
| 149 | } | |
| 150 | const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ | |
| 151 | nextWorktreePath: worktreePath, | |
| 152 | currentWorktreePath: activeWorktreePath, | |
| 153 | effectiveEnvMode, | |
| 154 | }); | |
| 155 | setDraftThreadContext(draftId ?? threadRef, { | |
| 156 | branch, | |
| 157 | worktreePath, | |
| 158 | envMode: nextDraftEnvMode, | |
| 159 | projectRef: scopeProjectRef(environmentId, activeProject.id), | |
| 160 | }); | |
| 161 | }, | |
| 162 | [ | |
| 163 | activeThreadId, | |
| 164 | activeProject, | |
| 165 | serverSession, | |
| 166 | activeWorktreePath, | |
| 167 | hasServerThread, | |
| 168 | setThreadBranchAction, | |
| 169 | setDraftThreadContext, | |
| 170 | draftId, | |
| 171 | threadRef, | |
| 172 | environmentId, | |
| 173 | effectiveEnvMode, | |
| 174 | ], | |
| 175 | ); | |
| 176 | ||
| 177 | // --------------------------------------------------------------------------- | |
| 178 | // Git branch queries | |
| 179 | // --------------------------------------------------------------------------- | |
| 85 | 180 | const queryClient = useQueryClient(); |
| 86 | 181 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 87 | 182 | const [branchQuery, setBranchQuery] = useState(""); |
| ... | ... | @@ -183,6 +278,9 @@ export function BranchToolbarBranchSelector({ |
| 183 | 278 | ? `Showing ${branches.length} of ${totalBranchCount} branches` |
| 184 | 279 | : null; |
| 185 | 280 | |
| 281 | // --------------------------------------------------------------------------- | |
| 282 | // Branch actions | |
| 283 | // --------------------------------------------------------------------------- | |
| 186 | 284 | const runBranchAction = (action: () => Promise<void>) => { |
| 187 | 285 | startBranchActionTransition(async () => { |
| 188 | 286 | await action().catch(() => undefined); |
| ... | ... | @@ -194,11 +292,10 @@ export function BranchToolbarBranchSelector({ |
| 194 | 292 | |
| 195 | 293 | const selectBranch = (branch: GitBranch) => { |
| 196 | 294 | const api = readEnvironmentApi(environmentId); |
| 197 | if (!api || !branchCwd || isBranchActionPending) return; | |
| 295 | if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; | |
| 198 | 296 | |
| 199 | // In new-worktree mode, selecting a branch sets the base branch. | |
| 200 | 297 | if (isSelectingWorktreeBase) { |
| 201 | onSetThreadBranch(branch.name, null); | |
| 298 | setThreadBranch(branch.name, null); | |
| 202 | 299 | setIsBranchMenuOpen(false); |
| 203 | 300 | onComposerFocusRequest?.(); |
| 204 | 301 | return; |
| ... | ... | @@ -210,9 +307,8 @@ export function BranchToolbarBranchSelector({ |
| 210 | 307 | branch, |
| 211 | 308 | }); |
| 212 | 309 | |
| 213 | // If the branch already lives in a worktree, point the thread there. | |
| 214 | 310 | if (selectionTarget.reuseExistingWorktree) { |
| 215 | onSetThreadBranch(branch.name, selectionTarget.nextWorktreePath); | |
| 311 | setThreadBranch(branch.name, selectionTarget.nextWorktreePath); | |
| 216 | 312 | setIsBranchMenuOpen(false); |
| 217 | 313 | onComposerFocusRequest?.(); |
| 218 | 314 | return; |
| ... | ... | @@ -237,7 +333,7 @@ export function BranchToolbarBranchSelector({ |
| 237 | 333 | ? (checkoutResult.branch ?? selectedBranchName) |
| 238 | 334 | : selectedBranchName; |
| 239 | 335 | setOptimisticBranch(nextBranchName); |
| 240 | onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); | |
| 336 | setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); | |
| 241 | 337 | } catch (error) { |
| 242 | 338 | setOptimisticBranch(previousBranch); |
| 243 | 339 | toastManager.add({ |
| ... | ... | @@ -267,7 +363,7 @@ export function BranchToolbarBranchSelector({ |
| 267 | 363 | checkout: true, |
| 268 | 364 | }); |
| 269 | 365 | setOptimisticBranch(createBranchResult.branch); |
| 270 | onSetThreadBranch(createBranchResult.branch, activeWorktreePath); | |
| 366 | setThreadBranch(createBranchResult.branch, activeWorktreePath); | |
| 271 | 367 | } catch (error) { |
| 272 | 368 | setOptimisticBranch(previousBranch); |
| 273 | 369 | toastManager.add({ |
| ... | ... | @@ -288,15 +384,12 @@ export function BranchToolbarBranchSelector({ |
| 288 | 384 | ) { |
| 289 | 385 | return; |
| 290 | 386 | } |
| 291 | onSetThreadBranch(currentGitBranch, null); | |
| 292 | }, [ | |
| 293 | activeThreadBranch, | |
| 294 | activeWorktreePath, | |
| 295 | currentGitBranch, | |
| 296 | effectiveEnvMode, | |
| 297 | onSetThreadBranch, | |
| 298 | ]); | |
| 387 | setThreadBranch(currentGitBranch, null); | |
| 388 | }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); | |
| 299 | 389 | |
| 390 | // --------------------------------------------------------------------------- | |
| 391 | // Combobox / virtualizer plumbing | |
| 392 | // --------------------------------------------------------------------------- | |
| 300 | 393 | const handleOpenChange = useCallback( |
| 301 | 394 | (open: boolean) => { |
| 302 | 395 | setIsBranchMenuOpen(open); |
| ... | ... | @@ -437,7 +530,7 @@ export function BranchToolbarBranchSelector({ |
| 437 | 530 | style={style} |
| 438 | 531 | onClick={() => createBranch(trimmedBranchQuery)} |
| 439 | 532 | > |
| 440 | <span className="truncate">Create new branch "{trimmedBranchQuery}"</span> | |
| 533 | <span className="truncate">Create new branch &quot;{trimmedBranchQuery}&quot;</span> | |
| 441 | 534 | </ComboboxItem> |
| 442 | 535 | ); |
| 443 | 536 | } |
| ... | ... | @@ -445,7 +538,8 @@ export function BranchToolbarBranchSelector({ |
| 445 | 538 | const branch = branchByName.get(itemValue); |
| 446 | 539 | if (!branch) return null; |
| 447 | 540 | |
| 448 | const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProjectCwd; | |
| 541 | const hasSecondaryWorktree = | |
| 542 | branch.worktreePath && activeProjectCwd && branch.worktreePath !== activeProjectCwd; | |
| 449 | 543 | const badge = branch.current |
| 450 | 544 | ? "current" |
| 451 | 545 | : hasSecondaryWorktree |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx created+73| ... | ... | @@ -0,0 +1,73 @@ |
| 1 | import { FolderIcon, GitForkIcon } from "lucide-react"; | |
| 2 | import { memo } from "react"; | |
| 3 | ||
| 4 | import type { EnvMode } from "./BranchToolbar.logic"; | |
| 5 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | |
| 6 | ||
| 7 | const envModeItems = [ | |
| 8 | { value: "local", label: "Local" }, | |
| 9 | { value: "worktree", label: "New worktree" }, | |
| 10 | ] as const; | |
| 11 | ||
| 12 | interface BranchToolbarEnvModeSelectorProps { | |
| 13 | envLocked: boolean; | |
| 14 | effectiveEnvMode: EnvMode; | |
| 15 | activeWorktreePath: string | null; | |
| 16 | onEnvModeChange: (mode: EnvMode) => void; | |
| 17 | } | |
| 18 | ||
| 19 | export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ | |
| 20 | envLocked, | |
| 21 | effectiveEnvMode, | |
| 22 | activeWorktreePath, | |
| 23 | onEnvModeChange, | |
| 24 | }: BranchToolbarEnvModeSelectorProps) { | |
| 25 | if (envLocked || activeWorktreePath) { | |
| 26 | return ( | |
| 27 | <span className="inline-flex items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs"> | |
| 28 | {activeWorktreePath ? ( | |
| 29 | <> | |
| 30 | <GitForkIcon className="size-3" /> | |
| 31 | Worktree | |
| 32 | </> | |
| 33 | ) : ( | |
| 34 | <> | |
| 35 | <FolderIcon className="size-3" /> | |
| 36 | Local | |
| 37 | </> | |
| 38 | )} | |
| 39 | </span> | |
| 40 | ); | |
| 41 | } | |
| 42 | ||
| 43 | return ( | |
| 44 | <Select | |
| 45 | value={effectiveEnvMode} | |
| 46 | onValueChange={(value) => onEnvModeChange(value as EnvMode)} | |
| 47 | items={envModeItems} | |
| 48 | > | |
| 49 | <SelectTrigger variant="ghost" size="xs" className="font-medium"> | |
| 50 | {effectiveEnvMode === "worktree" ? ( | |
| 51 | <GitForkIcon className="size-3" /> | |
| 52 | ) : ( | |
| 53 | <FolderIcon className="size-3" /> | |
| 54 | )} | |
| 55 | <SelectValue /> | |
| 56 | </SelectTrigger> | |
| 57 | <SelectPopup> | |
| 58 | <SelectItem value="local"> | |
| 59 | <span className="inline-flex items-center gap-1.5"> | |
| 60 | <FolderIcon className="size-3" /> | |
| 61 | Local | |
| 62 | </span> | |
| 63 | </SelectItem> | |
| 64 | <SelectItem value="worktree"> | |
| 65 | <span className="inline-flex items-center gap-1.5"> | |
| 66 | <GitForkIcon className="size-3" /> | |
| 67 | New worktree | |
| 68 | </span> | |
| 69 | </SelectItem> | |
| 70 | </SelectPopup> | |
| 71 | </Select> | |
| 72 | ); | |
| 73 | }); |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { CloudIcon, FolderIcon, ServerIcon } from "lucide-react"; | |
| 3 | import { memo, useMemo } from "react"; | |
| 4 | ||
| 5 | import type { EnvironmentOption } from "./BranchToolbar.logic"; | |
| 6 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | |
| 7 | ||
| 8 | interface BranchToolbarEnvironmentSelectorProps { | |
| 9 | envLocked: boolean; | |
| 10 | environmentId: EnvironmentId; | |
| 11 | availableEnvironments: readonly EnvironmentOption[]; | |
| 12 | onEnvironmentChange: (environmentId: EnvironmentId) => void; | |
| 13 | } | |
| 14 | ||
| 15 | export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ | |
| 16 | envLocked, | |
| 17 | environmentId, | |
| 18 | availableEnvironments, | |
| 19 | onEnvironmentChange, | |
| 20 | }: BranchToolbarEnvironmentSelectorProps) { | |
| 21 | const activeEnvironmentLabel = useMemo(() => { | |
| 22 | return availableEnvironments.find((env) => env.environmentId === environmentId)?.label ?? null; | |
| 23 | }, [availableEnvironments, environmentId]); | |
| 24 | ||
| 25 | const environmentItems = useMemo( | |
| 26 | () => | |
| 27 | availableEnvironments.map((env) => ({ | |
| 28 | value: env.environmentId, | |
| 29 | label: env.label, | |
| 30 | })), | |
| 31 | [availableEnvironments], | |
| 32 | ); | |
| 33 | ||
| 34 | if (envLocked) { | |
| 35 | return ( | |
| 36 | <span className="inline-flex items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs"> | |
| 37 | <ServerIcon className="size-3" /> | |
| 38 | {activeEnvironmentLabel ?? "Environment"} | |
| 39 | </span> | |
| 40 | ); | |
| 41 | } | |
| 42 | ||
| 43 | return ( | |
| 44 | <Select | |
| 45 | value={environmentId} | |
| 46 | onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} | |
| 47 | items={environmentItems} | |
| 48 | > | |
| 49 | <SelectTrigger variant="ghost" size="xs" className="font-medium"> | |
| 50 | <ServerIcon className="size-3" /> | |
| 51 | <SelectValue /> | |
| 52 | </SelectTrigger> | |
| 53 | <SelectPopup> | |
| 54 | {availableEnvironments.map((env) => ( | |
| 55 | <SelectItem key={env.environmentId} value={env.environmentId}> | |
| 56 | <span className="inline-flex items-center gap-1.5"> | |
| 57 | {env.isPrimary ? <FolderIcon className="size-3" /> : <CloudIcon className="size-3" />} | |
| 58 | {env.label} | |
| 59 | </span> | |
| 60 | </SelectItem> | |
| 61 | ))} | |
| 62 | </SelectPopup> | |
| 63 | </Select> | |
| 64 | ); | |
| 65 | }); |
apps/web/src/components/ChatView.browser.tsx+22-4| ... | ... | @@ -44,6 +44,7 @@ import { getRouter } from "../router"; |
| 44 | 44 | import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; |
| 45 | 45 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 46 | 46 | import { useUiStateStore } from "../uiStateStore"; |
| 47 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; | |
| 47 | 48 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; |
| 48 | 49 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 49 | 50 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| ... | ... | @@ -148,6 +149,12 @@ function createBaseServerConfig(): ServerConfig { |
| 148 | 149 | serverVersion: "0.0.0-test", |
| 149 | 150 | capabilities: { repositoryIdentity: true }, |
| 150 | 151 | }, |
| 152 | auth: { | |
| 153 | policy: "loopback-browser", | |
| 154 | bootstrapMethods: ["one-time-token"], | |
| 155 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 156 | sessionCookieName: "t3_session", | |
| 157 | }, | |
| 151 | 158 | cwd: "/repo/project", |
| 152 | 159 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 153 | 160 | keybindings: [], |
| ... | ... | @@ -821,6 +828,7 @@ const worker = setupWorker( |
| 821 | 828 | void rpcHarness.onMessage(rawData); |
| 822 | 829 | }); |
| 823 | 830 | }), |
| 831 | ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), | |
| 824 | 832 | http.get("*/attachments/:attachmentId", () => |
| 825 | 833 | HttpResponse.text(ATTACHMENT_SVG, { |
| 826 | 834 | headers: { |
| ... | ... | @@ -2629,10 +2637,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2629 | 2637 | "Promoted drafts should canonicalize to the server thread route.", |
| 2630 | 2638 | ); |
| 2631 | 2639 | |
| 2632 | // The empty thread view and composer should still be visible. | |
| 2633 | await expect | |
| 2634 | .element(page.getByText("Send a message to start the conversation.")) | |
| 2635 | .toBeInTheDocument(); | |
| 2640 | // The composer should remain usable after canonicalization, regardless of | |
| 2641 | // whether the promoted thread is still visibly empty or has already | |
| 2642 | // entered the running state. | |
| 2636 | 2643 | await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); |
| 2637 | 2644 | } finally { |
| 2638 | 2645 | await mounted.cleanup(); |
| ... | ... | @@ -2992,6 +2999,17 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2992 | 2999 | const promotedThreadId = draftThreadIdFor(promotedDraftId); |
| 2993 | 3000 | |
| 2994 | 3001 | await promoteDraftThreadViaDomainEvent(promotedThreadId); |
| 3002 | await waitForURL( | |
| 3003 | mounted.router, | |
| 3004 | (path) => path === serverThreadPath(promotedThreadId), | |
| 3005 | "Promoted drafts should canonicalize to the server thread route before a fresh draft is created.", | |
| 3006 | ); | |
| 3007 | await vi.waitFor( | |
| 3008 | () => { | |
| 3009 | expect(useComposerDraftStore.getState().getDraftThread(promotedDraftId)).toBeNull(); | |
| 3010 | }, | |
| 3011 | { timeout: 8_000, interval: 16 }, | |
| 3012 | ); | |
| 2995 | 3013 | |
| 2996 | 3014 | const freshThreadPath = await triggerChatNewShortcutUntilPath( |
| 2997 | 3015 | mounted.router, |
apps/web/src/components/ChatView.tsx+224-53| ... | ... | @@ -38,6 +38,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 38 | 38 | import { useShallow } from "zustand/react/shallow"; |
| 39 | 39 | import { useGitStatus } from "~/lib/gitStatusState"; |
| 40 | 40 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 41 | import { usePrimaryEnvironmentId } from "../environments/primary"; | |
| 41 | 42 | import { readEnvironmentApi } from "../environmentApi"; |
| 42 | 43 | import { isElectron } from "../env"; |
| 43 | 44 | import { readLocalApi } from "../localApi"; |
| ... | ... | @@ -75,7 +76,11 @@ import { |
| 75 | 76 | togglePendingUserInputOptionSelection, |
| 76 | 77 | type PendingUserInputDraftAnswer, |
| 77 | 78 | } from "../pendingUserInput"; |
| 78 | import { selectThreadsAcrossEnvironments, useStore } from "../store"; | |
| 79 | import { | |
| 80 | selectProjectsAcrossEnvironments, | |
| 81 | selectThreadsAcrossEnvironments, | |
| 82 | useStore, | |
| 83 | } from "../store"; | |
| 79 | 84 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; |
| 80 | 85 | import { useUiStateStore } from "../uiStateStore"; |
| 81 | 86 | import { |
| ... | ... | @@ -97,7 +102,7 @@ import { |
| 97 | 102 | import { basenameOfPath } from "../vscode-icons"; |
| 98 | 103 | import { useTheme } from "../hooks/useTheme"; |
| 99 | 104 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 100 | import BranchToolbar from "./BranchToolbar"; | |
| 105 | import { BranchToolbar } from "./BranchToolbar"; | |
| 101 | 106 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 102 | 107 | import PlanSidebar from "./PlanSidebar"; |
| 103 | 108 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| ... | ... | @@ -124,7 +129,6 @@ import { |
| 124 | 129 | nextProjectScriptId, |
| 125 | 130 | projectScriptIdFromCommand, |
| 126 | 131 | } from "~/projectScripts"; |
| 127 | import { SidebarTrigger } from "./ui/sidebar"; | |
| 128 | 132 | import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 129 | 133 | import { |
| 130 | 134 | getProviderModelCapabilities, |
| ... | ... | @@ -135,6 +139,10 @@ import { useSettings } from "../hooks/useSettings"; |
| 135 | 139 | import { resolveAppModelSelection } from "../modelSelection"; |
| 136 | 140 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 137 | 141 | import { deriveLogicalProjectKey } from "../logicalProject"; |
| 142 | import { | |
| 143 | useSavedEnvironmentRegistryStore, | |
| 144 | useSavedEnvironmentRuntimeStore, | |
| 145 | } from "../environments/runtime"; | |
| 138 | 146 | import { buildDraftThreadRouteParams } from "../threadRoutes"; |
| 139 | 147 | import { |
| 140 | 148 | type ComposerImageAttachment, |
| ... | ... | @@ -167,6 +175,7 @@ import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 167 | 175 | import { ChatHeader } from "./chat/ChatHeader"; |
| 168 | 176 | import { ContextWindowMeter } from "./chat/ContextWindowMeter"; |
| 169 | 177 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 178 | import { NoActiveThreadState } from "./NoActiveThreadState"; | |
| 170 | 179 | import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; |
| 171 | 180 | import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; |
| 172 | 181 | import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; |
| ... | ... | @@ -212,7 +221,6 @@ import { |
| 212 | 221 | } from "~/rpc/serverState"; |
| 213 | 222 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 214 | 223 | |
| 215 | const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; | |
| 216 | 224 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 217 | 225 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 218 | 226 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| ... | ... | @@ -813,7 +821,7 @@ export default function ChatView(props: ChatViewProps) { |
| 813 | 821 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); |
| 814 | 822 | const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null); |
| 815 | 823 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 816 | const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({}); | |
| 824 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); | |
| 817 | 825 | const sendInFlightRef = useRef(false); |
| 818 | 826 | const dragDepthRef = useRef(0); |
| 819 | 827 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| ... | ... | @@ -998,6 +1006,54 @@ export default function ChatView(props: ChatViewProps) { |
| 998 | 1006 | useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), |
| 999 | 1007 | ); |
| 1000 | 1008 | |
| 1009 | // Compute the list of environments this logical project spans, used to | |
| 1010 | // drive the environment picker in BranchToolbar. | |
| 1011 | const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); | |
| 1012 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 1013 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); | |
| 1014 | const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); | |
| 1015 | const logicalProjectEnvironments = useMemo(() => { | |
| 1016 | if (!activeProject) return []; | |
| 1017 | const logicalKey = deriveLogicalProjectKey(activeProject); | |
| 1018 | const memberProjects = allProjects.filter((p) => deriveLogicalProjectKey(p) === logicalKey); | |
| 1019 | const seen = new Set<string>(); | |
| 1020 | const envs: Array<{ | |
| 1021 | environmentId: EnvironmentId; | |
| 1022 | projectId: ProjectId; | |
| 1023 | label: string; | |
| 1024 | isPrimary: boolean; | |
| 1025 | }> = []; | |
| 1026 | for (const p of memberProjects) { | |
| 1027 | if (seen.has(p.environmentId)) continue; | |
| 1028 | seen.add(p.environmentId); | |
| 1029 | const isPrimary = p.environmentId === primaryEnvironmentId; | |
| 1030 | const savedRecord = savedEnvironmentRegistry[p.environmentId]; | |
| 1031 | const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; | |
| 1032 | const label = isPrimary | |
| 1033 | ? "Local" | |
| 1034 | : (runtimeState?.descriptor?.label ?? savedRecord?.label ?? p.environmentId); | |
| 1035 | envs.push({ | |
| 1036 | environmentId: p.environmentId, | |
| 1037 | projectId: p.id, | |
| 1038 | label, | |
| 1039 | isPrimary, | |
| 1040 | }); | |
| 1041 | } | |
| 1042 | // Sort: primary first, then alphabetical | |
| 1043 | envs.sort((a, b) => { | |
| 1044 | if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; | |
| 1045 | return a.label.localeCompare(b.label); | |
| 1046 | }); | |
| 1047 | return envs; | |
| 1048 | }, [ | |
| 1049 | activeProject, | |
| 1050 | allProjects, | |
| 1051 | primaryEnvironmentId, | |
| 1052 | savedEnvironmentRegistry, | |
| 1053 | savedEnvironmentRuntimeById, | |
| 1054 | ]); | |
| 1055 | const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; | |
| 1056 | ||
| 1001 | 1057 | const openPullRequestDialog = useCallback( |
| 1002 | 1058 | (reference?: string) => { |
| 1003 | 1059 | if (!canCheckoutPullRequestIntoThread) { |
| ... | ... | @@ -1127,7 +1183,17 @@ export default function ChatView(props: ChatViewProps) { |
| 1127 | 1183 | const lockedProvider: ProviderKind | null = hasThreadStarted |
| 1128 | 1184 | ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) |
| 1129 | 1185 | : null; |
| 1130 | const serverConfig = useServerConfig(); | |
| 1186 | const primaryServerConfig = useServerConfig(); | |
| 1187 | const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => | |
| 1188 | activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, | |
| 1189 | ); | |
| 1190 | // Use the server config for the thread's environment. For the primary | |
| 1191 | // environment fall back to the global atom; for remote environments use | |
| 1192 | // the runtime state stored by the environment manager. | |
| 1193 | const serverConfig = | |
| 1194 | primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId | |
| 1195 | ? primaryServerConfig | |
| 1196 | : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); | |
| 1131 | 1197 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; |
| 1132 | 1198 | const unlockedSelectedProvider = resolveSelectableProvider( |
| 1133 | 1199 | providerStatuses, |
| ... | ... | @@ -1338,11 +1404,28 @@ export default function ChatView(props: ChatViewProps) { |
| 1338 | 1404 | useEffect(() => { |
| 1339 | 1405 | attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; |
| 1340 | 1406 | }, [attachmentPreviewHandoffByMessageId]); |
| 1407 | const clearAttachmentPreviewHandoff = useCallback( | |
| 1408 | (messageId: MessageId, previewUrls?: ReadonlyArray<string>) => { | |
| 1409 | delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; | |
| 1410 | const currentPreviewUrls = | |
| 1411 | previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; | |
| 1412 | setAttachmentPreviewHandoffByMessageId((existing) => { | |
| 1413 | if (!(messageId in existing)) { | |
| 1414 | return existing; | |
| 1415 | } | |
| 1416 | const next = { ...existing }; | |
| 1417 | delete next[messageId]; | |
| 1418 | attachmentPreviewHandoffByMessageIdRef.current = next; | |
| 1419 | return next; | |
| 1420 | }); | |
| 1421 | for (const previewUrl of currentPreviewUrls) { | |
| 1422 | revokeBlobPreviewUrl(previewUrl); | |
| 1423 | } | |
| 1424 | }, | |
| 1425 | [], | |
| 1426 | ); | |
| 1341 | 1427 | const clearAttachmentPreviewHandoffs = useCallback(() => { |
| 1342 | for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) { | |
| 1343 | window.clearTimeout(timeoutId); | |
| 1344 | } | |
| 1345 | attachmentPreviewHandoffTimeoutByMessageIdRef.current = {}; | |
| 1428 | attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; | |
| 1346 | 1429 | for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { |
| 1347 | 1430 | for (const previewUrl of previewUrls) { |
| 1348 | 1431 | revokeBlobPreviewUrl(previewUrl); |
| ... | ... | @@ -1376,29 +1459,89 @@ export default function ChatView(props: ChatViewProps) { |
| 1376 | 1459 | attachmentPreviewHandoffByMessageIdRef.current = next; |
| 1377 | 1460 | return next; |
| 1378 | 1461 | }); |
| 1379 | ||
| 1380 | const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; | |
| 1381 | if (typeof existingTimeout === "number") { | |
| 1382 | window.clearTimeout(existingTimeout); | |
| 1462 | }, []); | |
| 1463 | const serverMessages = activeThread?.messages; | |
| 1464 | useEffect(() => { | |
| 1465 | if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) { | |
| 1466 | return; | |
| 1383 | 1467 | } |
| 1384 | attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => { | |
| 1385 | const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId]; | |
| 1386 | if (currentPreviewUrls) { | |
| 1387 | for (const previewUrl of currentPreviewUrls) { | |
| 1388 | revokeBlobPreviewUrl(previewUrl); | |
| 1389 | } | |
| 1468 | ||
| 1469 | const cleanups: Array<() => void> = []; | |
| 1470 | ||
| 1471 | for (const [messageId, handoffPreviewUrls] of Object.entries( | |
| 1472 | attachmentPreviewHandoffByMessageId, | |
| 1473 | )) { | |
| 1474 | if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { | |
| 1475 | continue; | |
| 1390 | 1476 | } |
| 1391 | setAttachmentPreviewHandoffByMessageId((existing) => { | |
| 1392 | if (!(messageId in existing)) return existing; | |
| 1393 | const next = { ...existing }; | |
| 1394 | delete next[messageId]; | |
| 1395 | attachmentPreviewHandoffByMessageIdRef.current = next; | |
| 1396 | return next; | |
| 1477 | ||
| 1478 | const serverMessage = serverMessages.find( | |
| 1479 | (message) => message.id === messageId && message.role === "user", | |
| 1480 | ); | |
| 1481 | if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { | |
| 1482 | continue; | |
| 1483 | } | |
| 1484 | ||
| 1485 | const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => | |
| 1486 | attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [], | |
| 1487 | ); | |
| 1488 | if ( | |
| 1489 | serverPreviewUrls.length === 0 || | |
| 1490 | serverPreviewUrls.length !== handoffPreviewUrls.length || | |
| 1491 | serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) | |
| 1492 | ) { | |
| 1493 | continue; | |
| 1494 | } | |
| 1495 | ||
| 1496 | attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; | |
| 1497 | ||
| 1498 | let cancelled = false; | |
| 1499 | const imageInstances: HTMLImageElement[] = []; | |
| 1500 | ||
| 1501 | const preloadServerPreviews = Promise.all( | |
| 1502 | serverPreviewUrls.map( | |
| 1503 | (previewUrl) => | |
| 1504 | new Promise<void>((resolve, reject) => { | |
| 1505 | const image = new Image(); | |
| 1506 | imageInstances.push(image); | |
| 1507 | const handleLoad = () => resolve(); | |
| 1508 | const handleError = () => | |
| 1509 | reject(new Error(`Failed to load server preview for ${messageId}.`)); | |
| 1510 | image.addEventListener("load", handleLoad, { once: true }); | |
| 1511 | image.addEventListener("error", handleError, { once: true }); | |
| 1512 | image.src = previewUrl; | |
| 1513 | }), | |
| 1514 | ), | |
| 1515 | ); | |
| 1516 | ||
| 1517 | void preloadServerPreviews | |
| 1518 | .then(() => { | |
| 1519 | if (cancelled) { | |
| 1520 | return; | |
| 1521 | } | |
| 1522 | clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls); | |
| 1523 | }) | |
| 1524 | .catch(() => { | |
| 1525 | if (!cancelled) { | |
| 1526 | delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; | |
| 1527 | } | |
| 1528 | }); | |
| 1529 | ||
| 1530 | cleanups.push(() => { | |
| 1531 | cancelled = true; | |
| 1532 | delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; | |
| 1533 | for (const image of imageInstances) { | |
| 1534 | image.src = ""; | |
| 1535 | } | |
| 1397 | 1536 | }); |
| 1398 | delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; | |
| 1399 | }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); | |
| 1400 | }, []); | |
| 1401 | const serverMessages = activeThread?.messages; | |
| 1537 | } | |
| 1538 | ||
| 1539 | return () => { | |
| 1540 | for (const cleanup of cleanups) { | |
| 1541 | cleanup(); | |
| 1542 | } | |
| 1543 | }; | |
| 1544 | }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); | |
| 1402 | 1545 | const timelineMessages = useMemo(() => { |
| 1403 | 1546 | const messages = serverMessages ?? []; |
| 1404 | 1547 | const serverMessagesWithPreviewHandoff = |
| ... | ... | @@ -1537,7 +1680,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1537 | 1680 | const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); |
| 1538 | 1681 | const keybindings = useServerKeybindings(); |
| 1539 | 1682 | const availableEditors = useServerAvailableEditors(); |
| 1540 | const modelOptionsByProvider = useMemo( | |
| 1683 | const modelOptionsByProvider = useMemo< | |
| 1684 | Record<ProviderKind, ReadonlyArray<ServerProvider["models"][number]>> | |
| 1685 | >( | |
| 1541 | 1686 | () => ({ |
| 1542 | 1687 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], |
| 1543 | 1688 | claudeAgent: |
| ... | ... | @@ -1730,6 +1875,47 @@ export default function ChatView(props: ChatViewProps) { |
| 1730 | 1875 | (activeThread.messages.length > 0 || |
| 1731 | 1876 | (activeThread.session !== null && activeThread.session.status !== "closed")), |
| 1732 | 1877 | ); |
| 1878 | ||
| 1879 | // Handle environment change for draft threads. When the user picks a | |
| 1880 | // different environment we update the draft context to point at the physical | |
| 1881 | // project in that environment while keeping the same logical project. | |
| 1882 | const onEnvironmentChange = useCallback( | |
| 1883 | (nextEnvironmentId: EnvironmentId) => { | |
| 1884 | if (envLocked || !draftId) return; | |
| 1885 | const target = logicalProjectEnvironments.find( | |
| 1886 | (env) => env.environmentId === nextEnvironmentId, | |
| 1887 | ); | |
| 1888 | if (!target) return; | |
| 1889 | setDraftThreadContext(draftId, { | |
| 1890 | projectRef: scopeProjectRef(target.environmentId, target.projectId), | |
| 1891 | }); | |
| 1892 | }, | |
| 1893 | [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], | |
| 1894 | ); | |
| 1895 | ||
| 1896 | // Auto-correct the draft model selection when the environment changes and | |
| 1897 | // the previously-selected provider/model is no longer available. This keeps | |
| 1898 | // the stored draft state consistent with the resolved picker values and | |
| 1899 | // prevents stale model references when the user sends a message. | |
| 1900 | const prevEnvironmentIdRef = useRef(activeThread?.environmentId); | |
| 1901 | useEffect(() => { | |
| 1902 | const currentEnvId = activeThread?.environmentId; | |
| 1903 | if (!currentEnvId || envLocked || prevEnvironmentIdRef.current === currentEnvId) { | |
| 1904 | prevEnvironmentIdRef.current = currentEnvId; | |
| 1905 | return; | |
| 1906 | } | |
| 1907 | prevEnvironmentIdRef.current = currentEnvId; | |
| 1908 | ||
| 1909 | // The resolved provider/model already account for the new environment's | |
| 1910 | // provider list. Persist that resolved selection into the draft. | |
| 1911 | if (activeThread) { | |
| 1912 | setComposerDraftModelSelection(scopeThreadRef(activeThread.environmentId, activeThread.id), { | |
| 1913 | provider: selectedProvider, | |
| 1914 | model: selectedModel, | |
| 1915 | }); | |
| 1916 | } | |
| 1917 | }, [activeThread, envLocked, selectedModel, selectedProvider, setComposerDraftModelSelection]); | |
| 1918 | ||
| 1733 | 1919 | const activeTerminalGroup = |
| 1734 | 1920 | terminalState.terminalGroups.find( |
| 1735 | 1921 | (group) => group.id === terminalState.activeTerminalGroupId, |
| ... | ... | @@ -4113,28 +4299,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4113 | 4299 | |
| 4114 | 4300 | // Empty state: no active thread |
| 4115 | 4301 | if (!activeThread) { |
| 4116 | return ( | |
| 4117 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> | |
| 4118 | {!isElectron && ( | |
| 4119 | <header className="border-b border-border px-3 py-2 md:hidden"> | |
| 4120 | <div className="flex items-center gap-2"> | |
| 4121 | <SidebarTrigger className="size-7 shrink-0" /> | |
| 4122 | <span className="text-sm font-medium text-foreground">Threads</span> | |
| 4123 | </div> | |
| 4124 | </header> | |
| 4125 | )} | |
| 4126 | {isElectron && ( | |
| 4127 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> | |
| 4128 | <span className="text-xs text-muted-foreground/50">No active thread</span> | |
| 4129 | </div> | |
| 4130 | )} | |
| 4131 | <div className="flex flex-1 items-center justify-center"> | |
| 4132 | <div className="text-center"> | |
| 4133 | <p className="text-sm">Select a thread or create a new one to get started.</p> | |
| 4134 | </div> | |
| 4135 | </div> | |
| 4136 | </div> | |
| 4137 | ); | |
| 4302 | return <NoActiveThreadState />; | |
| 4138 | 4303 | } |
| 4139 | 4304 | |
| 4140 | 4305 | return ( |
| ... | ... | @@ -4636,6 +4801,12 @@ export default function ChatView(props: ChatViewProps) { |
| 4636 | 4801 | {...(canCheckoutPullRequestIntoThread |
| 4637 | 4802 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } |
| 4638 | 4803 | : {})} |
| 4804 | {...(hasMultipleEnvironments | |
| 4805 | ? { | |
| 4806 | availableEnvironments: logicalProjectEnvironments, | |
| 4807 | onEnvironmentChange, | |
| 4808 | } | |
| 4809 | : {})} | |
| 4639 | 4810 | /> |
| 4640 | 4811 | )} |
| 4641 | 4812 | {pullRequestDialogState ? ( |
apps/web/src/components/GitActionsControl.browser.tsx+18-1| ... | ... | @@ -44,8 +44,12 @@ const { |
| 44 | 44 | toastUpdateSpy: vi.fn(), |
| 45 | 45 | })); |
| 46 | 46 | |
| 47 | vi.mock("@tanstack/react-query", () => { | |
| 47 | vi.mock("@tanstack/react-query", async () => { | |
| 48 | const actual = | |
| 49 | await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query"); | |
| 50 | ||
| 48 | 51 | return { |
| 52 | ...actual, | |
| 49 | 53 | useIsMutating: vi.fn(() => 0), |
| 50 | 54 | useMutation: vi.fn((options: { __kind?: string }) => { |
| 51 | 55 | if (options.__kind === "run-stacked-action") { |
| ... | ... | @@ -133,6 +137,19 @@ vi.mock("~/store", () => ({ |
| 133 | 137 | } |
| 134 | 138 | return environmentState; |
| 135 | 139 | }, |
| 140 | selectProjectsForEnvironment: () => [], | |
| 141 | selectProjectsAcrossEnvironments: () => [], | |
| 142 | selectThreadsForEnvironment: () => [], | |
| 143 | selectThreadsAcrossEnvironments: () => [], | |
| 144 | selectThreadShellsAcrossEnvironments: () => [], | |
| 145 | selectSidebarThreadsAcrossEnvironments: () => [], | |
| 146 | selectSidebarThreadsForProjectRef: () => [], | |
| 147 | selectSidebarThreadsForProjectRefs: () => [], | |
| 148 | selectBootstrapCompleteForActiveEnvironment: () => true, | |
| 149 | selectProjectByRef: () => null, | |
| 150 | selectThreadByRef: () => null, | |
| 151 | selectSidebarThreadSummaryByRef: () => null, | |
| 152 | selectThreadIdsByProjectRef: () => [], | |
| 136 | 153 | useStore: (selector: (state: unknown) => unknown) => |
| 137 | 154 | selector({ |
| 138 | 155 | setThreadBranch: setThreadBranchSpy, |
apps/web/src/components/KeybindingsToast.browser.tsx+44| ... | ... | @@ -22,8 +22,10 @@ import { useComposerDraftStore } from "../composerDraftStore"; |
| 22 | 22 | import { __resetLocalApiForTests } from "../localApi"; |
| 23 | 23 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; |
| 24 | 24 | import { getServerConfig } from "../rpc/serverState"; |
| 25 | import { getWsConnectionStatus } from "../rpc/wsConnectionState"; | |
| 25 | 26 | import { getRouter } from "../router"; |
| 26 | 27 | import { useStore } from "../store"; |
| 28 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; | |
| 27 | 29 | import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; |
| 28 | 30 | |
| 29 | 31 | vi.mock("../lib/gitStatusState", () => ({ |
| ... | ... | @@ -58,6 +60,12 @@ function createBaseServerConfig(): ServerConfig { |
| 58 | 60 | serverVersion: "0.0.0-test", |
| 59 | 61 | capabilities: { repositoryIdentity: true }, |
| 60 | 62 | }, |
| 63 | auth: { | |
| 64 | policy: "loopback-browser", | |
| 65 | bootstrapMethods: ["one-time-token"], | |
| 66 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 67 | sessionCookieName: "t3_session", | |
| 68 | }, | |
| 61 | 69 | cwd: "/repo/project", |
| 62 | 70 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 63 | 71 | keybindings: [], |
| ... | ... | @@ -210,6 +218,7 @@ const worker = setupWorker( |
| 210 | 218 | void rpcHarness.onMessage(rawData); |
| 211 | 219 | }); |
| 212 | 220 | }), |
| 221 | ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), | |
| 213 | 222 | http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), |
| 214 | 223 | http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), |
| 215 | 224 | ); |
| ... | ... | @@ -250,6 +259,22 @@ async function waitForComposerEditor(): Promise<HTMLElement> { |
| 250 | 259 | ); |
| 251 | 260 | } |
| 252 | 261 | |
| 262 | async function waitForToastViewport(): Promise<HTMLElement> { | |
| 263 | return waitForElement( | |
| 264 | () => document.querySelector<HTMLElement>('[data-slot="toast-viewport"]'), | |
| 265 | "App should render the toast viewport before server config updates are pushed", | |
| 266 | ); | |
| 267 | } | |
| 268 | ||
| 269 | async function waitForWsConnection(): Promise<void> { | |
| 270 | await vi.waitFor( | |
| 271 | () => { | |
| 272 | expect(getWsConnectionStatus().phase).toBe("connected"); | |
| 273 | }, | |
| 274 | { timeout: 8_000, interval: 16 }, | |
| 275 | ); | |
| 276 | } | |
| 277 | ||
| 253 | 278 | async function waitForToast(title: string, count = 1): Promise<void> { |
| 254 | 279 | await vi.waitFor( |
| 255 | 280 | () => { |
| ... | ... | @@ -269,6 +294,20 @@ async function waitForNoToast(title: string): Promise<void> { |
| 269 | 294 | ); |
| 270 | 295 | } |
| 271 | 296 | |
| 297 | async function waitForNoToasts(): Promise<void> { | |
| 298 | await vi.waitFor( | |
| 299 | () => { | |
| 300 | expect(queryToastTitles()).toHaveLength(0); | |
| 301 | }, | |
| 302 | { timeout: 8_000, interval: 16 }, | |
| 303 | ); | |
| 304 | } | |
| 305 | ||
| 306 | async function warmServerConfigUpdateStream(): Promise<void> { | |
| 307 | sendServerConfigUpdatedPush([]); | |
| 308 | await new Promise((resolve) => setTimeout(resolve, 50)); | |
| 309 | } | |
| 310 | ||
| 272 | 311 | async function waitForInitialWsSubscriptions(): Promise<void> { |
| 273 | 312 | await vi.waitFor( |
| 274 | 313 | () => { |
| ... | ... | @@ -313,8 +352,11 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { |
| 313 | 352 | { container: host }, |
| 314 | 353 | ); |
| 315 | 354 | await waitForComposerEditor(); |
| 355 | await waitForToastViewport(); | |
| 316 | 356 | await waitForInitialWsSubscriptions(); |
| 357 | await waitForWsConnection(); | |
| 317 | 358 | await waitForServerConfigSnapshot(); |
| 359 | await waitForNoToasts(); | |
| 318 | 360 | |
| 319 | 361 | return { |
| 320 | 362 | cleanup: async () => { |
| ... | ... | @@ -387,6 +429,8 @@ describe("Keybindings update toast", () => { |
| 387 | 429 | const mounted = await mountApp(); |
| 388 | 430 | |
| 389 | 431 | try { |
| 432 | await warmServerConfigUpdateStream(); | |
| 433 | ||
| 390 | 434 | sendServerConfigUpdatedPush([]); |
| 391 | 435 | await waitForToast("Keybindings updated", 1); |
| 392 | 436 |
apps/web/src/components/NoActiveThreadState.tsx created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; | |
| 2 | import { SidebarInset, SidebarTrigger } from "./ui/sidebar"; | |
| 3 | import { isElectron } from "../env"; | |
| 4 | import { cn } from "~/lib/utils"; | |
| 5 | ||
| 6 | export function NoActiveThreadState() { | |
| 7 | return ( | |
| 8 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | |
| 9 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> | |
| 10 | <header | |
| 11 | className={cn( | |
| 12 | "border-b border-border px-3 sm:px-5", | |
| 13 | isElectron ? "drag-region flex h-[52px] items-center" : "py-2 sm:py-3", | |
| 14 | )} | |
| 15 | > | |
| 16 | {isElectron ? ( | |
| 17 | <span className="text-xs text-muted-foreground/50">No active thread</span> | |
| 18 | ) : ( | |
| 19 | <div className="flex items-center gap-2"> | |
| 20 | <SidebarTrigger className="size-7 shrink-0 md:hidden" /> | |
| 21 | <span className="text-sm font-medium text-foreground md:text-muted-foreground/60"> | |
| 22 | No active thread | |
| 23 | </span> | |
| 24 | </div> | |
| 25 | )} | |
| 26 | </header> | |
| 27 | ||
| 28 | <Empty className="flex-1"> | |
| 29 | <div className="w-full max-w-lg rounded-3xl border border-border/55 bg-card/20 px-8 py-12 shadow-sm/5"> | |
| 30 | <EmptyHeader className="max-w-none"> | |
| 31 | <EmptyTitle className="text-foreground text-xl">Pick a thread to continue</EmptyTitle> | |
| 32 | <EmptyDescription className="mt-2 text-sm text-muted-foreground/78"> | |
| 33 | Select an existing thread or create a new one to get started. | |
| 34 | </EmptyDescription> | |
| 35 | </EmptyHeader> | |
| 36 | </div> | |
| 37 | </Empty> | |
| 38 | </div> | |
| 39 | </SidebarInset> | |
| 40 | ); | |
| 41 | } |
apps/web/src/components/ProjectFavicon.tsx+14-7| ... | ... | @@ -1,14 +1,19 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | 2 | import { FolderIcon } from "lucide-react"; |
| 2 | 3 | import { useState } from "react"; |
| 3 | import { resolveServerUrl } from "~/lib/utils"; | |
| 4 | import { resolveEnvironmentHttpUrl } from "../environments/runtime"; | |
| 4 | 5 | |
| 5 | 6 | const loadedProjectFaviconSrcs = new Set<string>(); |
| 6 | 7 | |
| 7 | export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) { | |
| 8 | const src = resolveServerUrl({ | |
| 9 | protocol: "http", | |
| 8 | export function ProjectFavicon(input: { | |
| 9 | environmentId: EnvironmentId; | |
| 10 | cwd: string; | |
| 11 | className?: string; | |
| 12 | }) { | |
| 13 | const src = resolveEnvironmentHttpUrl({ | |
| 14 | environmentId: input.environmentId, | |
| 10 | 15 | pathname: "/api/project-favicon", |
| 11 | searchParams: { cwd }, | |
| 16 | searchParams: { cwd: input.cwd }, | |
| 12 | 17 | }); |
| 13 | 18 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => |
| 14 | 19 | loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", |
| ... | ... | @@ -17,12 +22,14 @@ export function ProjectFavicon({ cwd, className }: { cwd: string; className?: st |
| 17 | 22 | return ( |
| 18 | 23 | <> |
| 19 | 24 | {status !== "loaded" ? ( |
| 20 | <FolderIcon className={`size-3.5 shrink-0 text-muted-foreground/50 ${className ?? ""}`} /> | |
| 25 | <FolderIcon | |
| 26 | className={`size-3.5 shrink-0 text-muted-foreground/50 ${input.className ?? ""}`} | |
| 27 | /> | |
| 21 | 28 | ) : null} |
| 22 | 29 | <img |
| 23 | 30 | src={src} |
| 24 | 31 | alt="" |
| 25 | className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loaded" ? "" : "hidden"} ${className ?? ""}`} | |
| 32 | className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loaded" ? "" : "hidden"} ${input.className ?? ""}`} | |
| 26 | 33 | onLoad={() => { |
| 27 | 34 | loadedProjectFaviconSrcs.add(src); |
| 28 | 35 | setStatus("loaded"); |
apps/web/src/components/Sidebar.tsx+279-87| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | ArchiveIcon, |
| 3 | 3 | ArrowUpDownIcon, |
| 4 | 4 | ChevronRightIcon, |
| 5 | CloudIcon, | |
| 5 | 6 | FolderIcon, |
| 6 | 7 | GitPullRequestIcon, |
| 7 | 8 | PlusIcon, |
| ... | ... | @@ -34,6 +35,7 @@ import { |
| 34 | 35 | type DesktopUpdateState, |
| 35 | 36 | type EnvironmentId, |
| 36 | 37 | ProjectId, |
| 38 | type ScopedProjectRef, | |
| 37 | 39 | type ScopedThreadRef, |
| 38 | 40 | type ThreadEnvMode, |
| 39 | 41 | ThreadId, |
| ... | ... | @@ -50,13 +52,16 @@ import { |
| 50 | 52 | type SidebarProjectSortOrder, |
| 51 | 53 | type SidebarThreadSortOrder, |
| 52 | 54 | } from "@t3tools/contracts/settings"; |
| 55 | import { usePrimaryEnvironmentId } from "../environments/primary"; | |
| 53 | 56 | import { isElectron } from "../env"; |
| 54 | 57 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 55 | 58 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 56 | 59 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; |
| 57 | 60 | import { |
| 61 | selectProjectByRef, | |
| 58 | 62 | selectProjectsAcrossEnvironments, |
| 59 | 63 | selectSidebarThreadsForProjectRef, |
| 64 | selectSidebarThreadsForProjectRefs, | |
| 60 | 65 | selectSidebarThreadsAcrossEnvironments, |
| 61 | 66 | selectThreadByRef, |
| 62 | 67 | useStore, |
| ... | ... | @@ -103,7 +108,6 @@ import { |
| 103 | 108 | SidebarFooter, |
| 104 | 109 | SidebarGroup, |
| 105 | 110 | SidebarHeader, |
| 106 | SidebarMenuAction, | |
| 107 | 111 | SidebarMenu, |
| 108 | 112 | SidebarMenuButton, |
| 109 | 113 | SidebarMenuItem, |
| ... | ... | @@ -135,6 +139,11 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 135 | 139 | import { readEnvironmentApi } from "../environmentApi"; |
| 136 | 140 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 137 | 141 | import { useServerKeybindings } from "../rpc/serverState"; |
| 142 | import { deriveLogicalProjectKey } from "../logicalProject"; | |
| 143 | import { | |
| 144 | useSavedEnvironmentRegistryStore, | |
| 145 | useSavedEnvironmentRuntimeStore, | |
| 146 | } from "../environments/runtime"; | |
| 138 | 147 | import type { Project, SidebarThreadSummary } from "../types"; |
| 139 | 148 | const THREAD_PREVIEW_LIMIT = 6; |
| 140 | 149 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| ... | ... | @@ -200,8 +209,14 @@ function buildThreadJumpLabelMap(input: { |
| 200 | 209 | return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; |
| 201 | 210 | } |
| 202 | 211 | |
| 212 | type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; | |
| 213 | ||
| 203 | 214 | type SidebarProjectSnapshot = Project & { |
| 204 | 215 | projectKey: string; |
| 216 | environmentPresence: EnvironmentPresence; | |
| 217 | memberProjectRefs: readonly ScopedProjectRef[]; | |
| 218 | /** Labels for remote environments this project lives in. */ | |
| 219 | remoteEnvironmentLabels: readonly string[]; | |
| 205 | 220 | }; |
| 206 | 221 | interface TerminalStatusIndicator { |
| 207 | 222 | label: "Terminal process running"; |
| ... | ... | @@ -381,7 +396,30 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 381 | 396 | (state) => |
| 382 | 397 | selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, |
| 383 | 398 | ); |
| 384 | const gitCwd = thread.worktreePath ?? props.projectCwd; | |
| 399 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 400 | const isRemoteThread = | |
| 401 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; | |
| 402 | const remoteEnvLabel = useSavedEnvironmentRuntimeStore( | |
| 403 | (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, | |
| 404 | ); | |
| 405 | const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( | |
| 406 | (s) => s.byId[thread.environmentId]?.label ?? null, | |
| 407 | ); | |
| 408 | const threadEnvironmentLabel = isRemoteThread | |
| 409 | ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") | |
| 410 | : null; | |
| 411 | // For grouped projects, the thread may belong to a different environment | |
| 412 | // than the representative project. Look up the thread's own project cwd | |
| 413 | // so git status (and thus PR detection) queries the correct path. | |
| 414 | const threadProjectCwd = useStore( | |
| 415 | useMemo( | |
| 416 | () => (state: import("../store").AppState) => | |
| 417 | selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? | |
| 418 | null, | |
| 419 | [thread.environmentId, thread.projectId], | |
| 420 | ), | |
| 421 | ); | |
| 422 | const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; | |
| 385 | 423 | const gitStatus = useGitStatus({ |
| 386 | 424 | environmentId: thread.environmentId, |
| 387 | 425 | cwd: thread.branch != null ? gitCwd : null, |
| ... | ... | @@ -673,24 +711,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 673 | 711 | ) |
| 674 | 712 | ) : null} |
| 675 | 713 | <span className={threadMetaClassName}> |
| 676 | {jumpLabel ? ( | |
| 677 | <span | |
| 678 | className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" | |
| 679 | title={jumpLabel} | |
| 680 | > | |
| 681 | {jumpLabel} | |
| 682 | </span> | |
| 683 | ) : ( | |
| 684 | <span | |
| 685 | className={`text-[10px] ${ | |
| 686 | isHighlighted | |
| 687 | ? "text-foreground/72 dark:text-foreground/82" | |
| 688 | : "text-muted-foreground/40" | |
| 689 | }`} | |
| 690 | > | |
| 691 | {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} | |
| 692 | </span> | |
| 693 | )} | |
| 714 | <span className="inline-flex items-center gap-1"> | |
| 715 | {isRemoteThread && ( | |
| 716 | <Tooltip> | |
| 717 | <TooltipTrigger | |
| 718 | render={ | |
| 719 | <span | |
| 720 | aria-label={threadEnvironmentLabel ?? "Remote"} | |
| 721 | className="inline-flex items-center justify-center" | |
| 722 | /> | |
| 723 | } | |
| 724 | > | |
| 725 | <CloudIcon className="size-3 text-muted-foreground/40" /> | |
| 726 | </TooltipTrigger> | |
| 727 | <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup> | |
| 728 | </Tooltip> | |
| 729 | )} | |
| 730 | {jumpLabel ? ( | |
| 731 | <span | |
| 732 | className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" | |
| 733 | title={jumpLabel} | |
| 734 | > | |
| 735 | {jumpLabel} | |
| 736 | </span> | |
| 737 | ) : ( | |
| 738 | <span | |
| 739 | className={`text-[10px] ${ | |
| 740 | isHighlighted | |
| 741 | ? "text-foreground/72 dark:text-foreground/82" | |
| 742 | : "text-muted-foreground/40" | |
| 743 | }`} | |
| 744 | > | |
| 745 | {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} | |
| 746 | </span> | |
| 747 | )} | |
| 748 | </span> | |
| 694 | 749 | </span> |
| 695 | 750 | </div> |
| 696 | 751 | </div> |
| ... | ... | @@ -998,7 +1053,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 998 | 1053 | const sidebarThreads = useStore( |
| 999 | 1054 | useShallow( |
| 1000 | 1055 | useMemo( |
| 1001 | () => (state) => | |
| 1056 | () => (state: import("../store").AppState) => | |
| 1002 | 1057 | selectSidebarThreadsForProjectRef( |
| 1003 | 1058 | state, |
| 1004 | 1059 | scopeProjectRef(project.environmentId, project.id), |
| ... | ... | @@ -1007,25 +1062,45 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1007 | 1062 | ), |
| 1008 | 1063 | ), |
| 1009 | 1064 | ); |
| 1065 | // For grouped projects that span multiple environments, also fetch | |
| 1066 | // threads from the other member project refs. | |
| 1067 | const otherMemberRefs = useMemo( | |
| 1068 | () => | |
| 1069 | project.memberProjectRefs.filter( | |
| 1070 | (ref) => ref.environmentId !== project.environmentId || ref.projectId !== project.id, | |
| 1071 | ), | |
| 1072 | [project.memberProjectRefs, project.environmentId, project.id], | |
| 1073 | ); | |
| 1074 | const otherMemberThreads = useStore( | |
| 1075 | useShallow( | |
| 1076 | useMemo( | |
| 1077 | () => | |
| 1078 | otherMemberRefs.length === 0 | |
| 1079 | ? () => [] as SidebarThreadSummary[] | |
| 1080 | : (state: import("../store").AppState) => | |
| 1081 | selectSidebarThreadsForProjectRefs(state, otherMemberRefs), | |
| 1082 | [otherMemberRefs], | |
| 1083 | ), | |
| 1084 | ), | |
| 1085 | ); | |
| 1086 | const allSidebarThreads = useMemo( | |
| 1087 | () => | |
| 1088 | otherMemberThreads.length === 0 ? sidebarThreads : [...sidebarThreads, ...otherMemberThreads], | |
| 1089 | [sidebarThreads, otherMemberThreads], | |
| 1090 | ); | |
| 1010 | 1091 | const sidebarThreadByKey = useMemo( |
| 1011 | 1092 | () => |
| 1012 | 1093 | new Map( |
| 1013 | sidebarThreads.map( | |
| 1094 | allSidebarThreads.map( | |
| 1014 | 1095 | (thread) => |
| 1015 | 1096 | [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, |
| 1016 | 1097 | ), |
| 1017 | 1098 | ), |
| 1018 | [sidebarThreads], | |
| 1019 | ); | |
| 1020 | const projectThreads = useMemo( | |
| 1021 | () => | |
| 1022 | sidebarThreads.filter( | |
| 1023 | (thread) => | |
| 1024 | scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)) === | |
| 1025 | project.projectKey, | |
| 1026 | ), | |
| 1027 | [project.projectKey, sidebarThreads], | |
| 1099 | [allSidebarThreads], | |
| 1028 | 1100 | ); |
| 1101 | // All threads from the representative + other member environments are | |
| 1102 | // already fetched into allSidebarThreads, so we can use them directly. | |
| 1103 | const projectThreads = allSidebarThreads; | |
| 1029 | 1104 | const projectExpanded = useUiStateStore( |
| 1030 | 1105 | (state) => state.projectExpandedById[project.projectKey] ?? true, |
| 1031 | 1106 | ); |
| ... | ... | @@ -1639,28 +1714,49 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1639 | 1714 | }`} |
| 1640 | 1715 | /> |
| 1641 | 1716 | )} |
| 1642 | <ProjectFavicon cwd={project.cwd} /> | |
| 1717 | <ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} /> | |
| 1643 | 1718 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> |
| 1644 | 1719 | {project.name} |
| 1645 | 1720 | </span> |
| 1646 | 1721 | </SidebarMenuButton> |
| 1722 | {/* Environment badge – visible by default, crossfades with the | |
| 1723 | "new thread" button on hover using the same pointer-events + | |
| 1724 | opacity pattern as the thread row archive/timestamp swap. */} | |
| 1725 | {project.environmentPresence === "remote-only" && ( | |
| 1726 | <Tooltip> | |
| 1727 | <TooltipTrigger | |
| 1728 | render={ | |
| 1729 | <span | |
| 1730 | aria-label={ | |
| 1731 | project.environmentPresence === "remote-only" | |
| 1732 | ? "Remote project" | |
| 1733 | : "Available in multiple environments" | |
| 1734 | } | |
| 1735 | className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/50 transition-opacity duration-150 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0" | |
| 1736 | /> | |
| 1737 | } | |
| 1738 | > | |
| 1739 | <CloudIcon className="size-3" /> | |
| 1740 | </TooltipTrigger> | |
| 1741 | <TooltipPopup side="top"> | |
| 1742 | Remote environment: {project.remoteEnvironmentLabels.join(", ")} | |
| 1743 | </TooltipPopup> | |
| 1744 | </Tooltip> | |
| 1745 | )} | |
| 1647 | 1746 | <Tooltip> |
| 1648 | 1747 | <TooltipTrigger |
| 1649 | 1748 | render={ |
| 1650 | <SidebarMenuAction | |
| 1651 | render={ | |
| 1652 | <button | |
| 1653 | type="button" | |
| 1654 | aria-label={`Create new thread in ${project.name}`} | |
| 1655 | data-testid="new-thread-button" | |
| 1656 | /> | |
| 1657 | } | |
| 1658 | showOnHover | |
| 1659 | className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" | |
| 1660 | onClick={handleCreateThreadClick} | |
| 1661 | > | |
| 1662 | <SquarePenIcon className="size-3.5" /> | |
| 1663 | </SidebarMenuAction> | |
| 1749 | <div className="pointer-events-none absolute top-1 right-1.5 opacity-0 transition-opacity duration-150 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100"> | |
| 1750 | <button | |
| 1751 | type="button" | |
| 1752 | aria-label={`Create new thread in ${project.name}`} | |
| 1753 | data-testid="new-thread-button" | |
| 1754 | className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/70 hover:bg-secondary hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" | |
| 1755 | onClick={handleCreateThreadClick} | |
| 1756 | > | |
| 1757 | <SquarePenIcon className="size-3.5" /> | |
| 1758 | </button> | |
| 1759 | </div> | |
| 1664 | 1760 | } |
| 1665 | 1761 | /> |
| 1666 | 1762 | <TooltipPopup side="top"> |
| ... | ... | @@ -2238,7 +2334,9 @@ export default function Sidebar() { |
| 2238 | 2334 | const navigate = useNavigate(); |
| 2239 | 2335 | const pathname = useLocation({ select: (loc) => loc.pathname }); |
| 2240 | 2336 | const isOnSettings = pathname.startsWith("/settings"); |
| 2241 | const appSettings = useSettings(); | |
| 2337 | const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); | |
| 2338 | const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); | |
| 2339 | const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); | |
| 2242 | 2340 | const { updateSettings } = useUpdateSettings(); |
| 2243 | 2341 | const { handleNewThread } = useHandleNewThread(); |
| 2244 | 2342 | const { archiveThread, deleteThread } = useThreadActions(); |
| ... | ... | @@ -2269,6 +2367,9 @@ export default function Sidebar() { |
| 2269 | 2367 | const platform = navigator.platform; |
| 2270 | 2368 | const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; |
| 2271 | 2369 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; |
| 2370 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 2371 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); | |
| 2372 | const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); | |
| 2272 | 2373 | const orderedProjects = useMemo(() => { |
| 2273 | 2374 | return orderItemsByPreferredIds({ |
| 2274 | 2375 | items: projects, |
| ... | ... | @@ -2276,14 +2377,89 @@ export default function Sidebar() { |
| 2276 | 2377 | getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), |
| 2277 | 2378 | }); |
| 2278 | 2379 | }, [projectOrder, projects]); |
| 2279 | const sidebarProjects = useMemo<SidebarProjectSnapshot[]>( | |
| 2280 | () => | |
| 2281 | orderedProjects.map((project) => ({ | |
| 2282 | ...project, | |
| 2283 | projectKey: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 2284 | })), | |
| 2285 | [orderedProjects], | |
| 2286 | ); | |
| 2380 | ||
| 2381 | // Build a mapping from physical project key → logical project key for | |
| 2382 | // cross-environment grouping. Projects that share a repositoryIdentity | |
| 2383 | // canonicalKey are treated as one logical project in the sidebar. | |
| 2384 | const physicalToLogicalKey = useMemo(() => { | |
| 2385 | const mapping = new Map<string, string>(); | |
| 2386 | for (const project of orderedProjects) { | |
| 2387 | const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); | |
| 2388 | mapping.set(physicalKey, deriveLogicalProjectKey(project)); | |
| 2389 | } | |
| 2390 | return mapping; | |
| 2391 | }, [orderedProjects]); | |
| 2392 | ||
| 2393 | const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(() => { | |
| 2394 | // Group projects by logical key while preserving insertion order from | |
| 2395 | // orderedProjects. | |
| 2396 | const groupedMembers = new Map<string, Project[]>(); | |
| 2397 | for (const project of orderedProjects) { | |
| 2398 | const logicalKey = deriveLogicalProjectKey(project); | |
| 2399 | const existing = groupedMembers.get(logicalKey); | |
| 2400 | if (existing) { | |
| 2401 | existing.push(project); | |
| 2402 | } else { | |
| 2403 | groupedMembers.set(logicalKey, [project]); | |
| 2404 | } | |
| 2405 | } | |
| 2406 | ||
| 2407 | const result: SidebarProjectSnapshot[] = []; | |
| 2408 | const seen = new Set<string>(); | |
| 2409 | for (const project of orderedProjects) { | |
| 2410 | const logicalKey = deriveLogicalProjectKey(project); | |
| 2411 | if (seen.has(logicalKey)) continue; | |
| 2412 | seen.add(logicalKey); | |
| 2413 | ||
| 2414 | const members = groupedMembers.get(logicalKey)!; | |
| 2415 | // Prefer the primary environment's project as the representative. | |
| 2416 | const representative: Project | undefined = | |
| 2417 | (primaryEnvironmentId | |
| 2418 | ? members.find((p) => p.environmentId === primaryEnvironmentId) | |
| 2419 | : undefined) ?? members[0]; | |
| 2420 | if (!representative) continue; | |
| 2421 | const hasLocal = | |
| 2422 | primaryEnvironmentId !== null && | |
| 2423 | members.some((p) => p.environmentId === primaryEnvironmentId); | |
| 2424 | const hasRemote = | |
| 2425 | primaryEnvironmentId !== null | |
| 2426 | ? members.some((p) => p.environmentId !== primaryEnvironmentId) | |
| 2427 | : false; | |
| 2428 | ||
| 2429 | const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); | |
| 2430 | const remoteLabels = members | |
| 2431 | .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) | |
| 2432 | .map((p) => { | |
| 2433 | const rt = savedEnvironmentRuntimeById[p.environmentId]; | |
| 2434 | const saved = savedEnvironmentRegistry[p.environmentId]; | |
| 2435 | return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; | |
| 2436 | }); | |
| 2437 | const snapshot: SidebarProjectSnapshot = { | |
| 2438 | id: representative.id, | |
| 2439 | environmentId: representative.environmentId, | |
| 2440 | name: representative.name, | |
| 2441 | cwd: representative.cwd, | |
| 2442 | repositoryIdentity: representative.repositoryIdentity ?? null, | |
| 2443 | defaultModelSelection: representative.defaultModelSelection, | |
| 2444 | createdAt: representative.createdAt, | |
| 2445 | updatedAt: representative.updatedAt, | |
| 2446 | scripts: representative.scripts, | |
| 2447 | projectKey: logicalKey, | |
| 2448 | environmentPresence: | |
| 2449 | hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", | |
| 2450 | memberProjectRefs: refs, | |
| 2451 | remoteEnvironmentLabels: remoteLabels, | |
| 2452 | }; | |
| 2453 | result.push(snapshot); | |
| 2454 | } | |
| 2455 | return result; | |
| 2456 | }, [ | |
| 2457 | orderedProjects, | |
| 2458 | primaryEnvironmentId, | |
| 2459 | savedEnvironmentRegistry, | |
| 2460 | savedEnvironmentRuntimeById, | |
| 2461 | ]); | |
| 2462 | ||
| 2287 | 2463 | const sidebarProjectByKey = useMemo( |
| 2288 | 2464 | () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), |
| 2289 | 2465 | [sidebarProjects], |
| ... | ... | @@ -2298,28 +2474,36 @@ export default function Sidebar() { |
| 2298 | 2474 | ), |
| 2299 | 2475 | [sidebarThreads], |
| 2300 | 2476 | ); |
| 2477 | // Resolve the active route's project key to a logical key so it matches the | |
| 2478 | // sidebar's grouped project entries. | |
| 2301 | 2479 | const activeRouteProjectKey = useMemo(() => { |
| 2302 | 2480 | if (!routeThreadKey) { |
| 2303 | 2481 | return null; |
| 2304 | 2482 | } |
| 2305 | 2483 | const activeThread = sidebarThreadByKey.get(routeThreadKey); |
| 2306 | return activeThread | |
| 2307 | ? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)) | |
| 2308 | : null; | |
| 2309 | }, [routeThreadKey, sidebarThreadByKey]); | |
| 2484 | if (!activeThread) return null; | |
| 2485 | const physicalKey = scopedProjectKey( | |
| 2486 | scopeProjectRef(activeThread.environmentId, activeThread.projectId), | |
| 2487 | ); | |
| 2488 | return physicalToLogicalKey.get(physicalKey) ?? physicalKey; | |
| 2489 | }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); | |
| 2490 | ||
| 2491 | // Group threads by logical project key so all threads from grouped projects | |
| 2492 | // are displayed together. | |
| 2310 | 2493 | const threadsByProjectKey = useMemo(() => { |
| 2311 | 2494 | const next = new Map<string, SidebarThreadSummary[]>(); |
| 2312 | 2495 | for (const thread of sidebarThreads) { |
| 2313 | const projectKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); | |
| 2314 | const existing = next.get(projectKey); | |
| 2496 | const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); | |
| 2497 | const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; | |
| 2498 | const existing = next.get(logicalKey); | |
| 2315 | 2499 | if (existing) { |
| 2316 | 2500 | existing.push(thread); |
| 2317 | 2501 | } else { |
| 2318 | next.set(projectKey, [thread]); | |
| 2502 | next.set(logicalKey, [thread]); | |
| 2319 | 2503 | } |
| 2320 | 2504 | } |
| 2321 | 2505 | return next; |
| 2322 | }, [sidebarThreads]); | |
| 2506 | }, [sidebarThreads, physicalToLogicalKey]); | |
| 2323 | 2507 | const getCurrentSidebarShortcutContext = useCallback( |
| 2324 | 2508 | () => ({ |
| 2325 | 2509 | terminalFocus: isTerminalFocused(), |
| ... | ... | @@ -2347,12 +2531,13 @@ export default function Sidebar() { |
| 2347 | 2531 | shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); |
| 2348 | 2532 | const focusMostRecentThreadForProject = useCallback( |
| 2349 | 2533 | (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { |
| 2350 | const projectKey = scopedProjectKey( | |
| 2534 | const physicalKey = scopedProjectKey( | |
| 2351 | 2535 | scopeProjectRef(projectRef.environmentId, projectRef.projectId), |
| 2352 | 2536 | ); |
| 2537 | const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; | |
| 2353 | 2538 | const latestThread = sortThreadsForSidebar( |
| 2354 | (threadsByProjectKey.get(projectKey) ?? []).filter((thread) => thread.archivedAt === null), | |
| 2355 | appSettings.sidebarThreadSortOrder, | |
| 2539 | (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), | |
| 2540 | sidebarThreadSortOrder, | |
| 2356 | 2541 | )[0]; |
| 2357 | 2542 | if (!latestThread) return; |
| 2358 | 2543 | |
| ... | ... | @@ -2361,7 +2546,7 @@ export default function Sidebar() { |
| 2361 | 2546 | params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), |
| 2362 | 2547 | }); |
| 2363 | 2548 | }, |
| 2364 | [appSettings.sidebarThreadSortOrder, navigate, threadsByProjectKey], | |
| 2549 | [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], | |
| 2365 | 2550 | ); |
| 2366 | 2551 | |
| 2367 | 2552 | const addProjectFromPath = useCallback( |
| ... | ... | @@ -2407,7 +2592,7 @@ export default function Sidebar() { |
| 2407 | 2592 | }); |
| 2408 | 2593 | if (activeEnvironmentId !== null) { |
| 2409 | 2594 | await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { |
| 2410 | envMode: appSettings.defaultThreadEnvMode, | |
| 2595 | envMode: defaultThreadEnvMode, | |
| 2411 | 2596 | }).catch(() => undefined); |
| 2412 | 2597 | } |
| 2413 | 2598 | } catch (error) { |
| ... | ... | @@ -2434,7 +2619,7 @@ export default function Sidebar() { |
| 2434 | 2619 | isAddingProject, |
| 2435 | 2620 | projects, |
| 2436 | 2621 | shouldBrowseForProjectImmediately, |
| 2437 | appSettings.defaultThreadEnvMode, | |
| 2622 | defaultThreadEnvMode, | |
| 2438 | 2623 | ], |
| 2439 | 2624 | ); |
| 2440 | 2625 | |
| ... | ... | @@ -2501,7 +2686,7 @@ export default function Sidebar() { |
| 2501 | 2686 | |
| 2502 | 2687 | const handleProjectDragEnd = useCallback( |
| 2503 | 2688 | (event: DragEndEvent) => { |
| 2504 | if (appSettings.sidebarProjectSortOrder !== "manual") { | |
| 2689 | if (sidebarProjectSortOrder !== "manual") { | |
| 2505 | 2690 | dragInProgressRef.current = false; |
| 2506 | 2691 | return; |
| 2507 | 2692 | } |
| ... | ... | @@ -2513,18 +2698,18 @@ export default function Sidebar() { |
| 2513 | 2698 | if (!activeProject || !overProject) return; |
| 2514 | 2699 | reorderProjects(activeProject.projectKey, overProject.projectKey); |
| 2515 | 2700 | }, |
| 2516 | [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], | |
| 2701 | [sidebarProjectSortOrder, reorderProjects, sidebarProjects], | |
| 2517 | 2702 | ); |
| 2518 | 2703 | |
| 2519 | 2704 | const handleProjectDragStart = useCallback( |
| 2520 | 2705 | (_event: DragStartEvent) => { |
| 2521 | if (appSettings.sidebarProjectSortOrder !== "manual") { | |
| 2706 | if (sidebarProjectSortOrder !== "manual") { | |
| 2522 | 2707 | return; |
| 2523 | 2708 | } |
| 2524 | 2709 | dragInProgressRef.current = true; |
| 2525 | 2710 | suppressProjectClickAfterDragRef.current = true; |
| 2526 | 2711 | }, |
| 2527 | [appSettings.sidebarProjectSortOrder], | |
| 2712 | [sidebarProjectSortOrder], | |
| 2528 | 2713 | ); |
| 2529 | 2714 | |
| 2530 | 2715 | const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { |
| ... | ... | @@ -2558,22 +2743,29 @@ export default function Sidebar() { |
| 2558 | 2743 | ...project, |
| 2559 | 2744 | id: project.projectKey, |
| 2560 | 2745 | })); |
| 2561 | const sortableThreads = visibleThreads.map((thread) => ({ | |
| 2562 | ...thread, | |
| 2563 | projectId: scopedProjectKey( | |
| 2564 | scopeProjectRef(thread.environmentId, thread.projectId), | |
| 2565 | ) as ProjectId, | |
| 2566 | })); | |
| 2746 | const sortableThreads = visibleThreads.map((thread) => { | |
| 2747 | const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); | |
| 2748 | return { | |
| 2749 | ...thread, | |
| 2750 | projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, | |
| 2751 | }; | |
| 2752 | }); | |
| 2567 | 2753 | return sortProjectsForSidebar( |
| 2568 | 2754 | sortableProjects, |
| 2569 | 2755 | sortableThreads, |
| 2570 | appSettings.sidebarProjectSortOrder, | |
| 2756 | sidebarProjectSortOrder, | |
| 2571 | 2757 | ).flatMap((project) => { |
| 2572 | 2758 | const resolvedProject = sidebarProjectByKey.get(project.id); |
| 2573 | 2759 | return resolvedProject ? [resolvedProject] : []; |
| 2574 | 2760 | }); |
| 2575 | }, [appSettings.sidebarProjectSortOrder, sidebarProjectByKey, sidebarProjects, visibleThreads]); | |
| 2576 | const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; | |
| 2761 | }, [ | |
| 2762 | sidebarProjectSortOrder, | |
| 2763 | physicalToLogicalKey, | |
| 2764 | sidebarProjectByKey, | |
| 2765 | sidebarProjects, | |
| 2766 | visibleThreads, | |
| 2767 | ]); | |
| 2768 | const isManualProjectSorting = sidebarProjectSortOrder === "manual"; | |
| 2577 | 2769 | const visibleSidebarThreadKeys = useMemo( |
| 2578 | 2770 | () => |
| 2579 | 2771 | sortedProjects.flatMap((project) => { |
| ... | ... | @@ -2581,7 +2773,7 @@ export default function Sidebar() { |
| 2581 | 2773 | (threadsByProjectKey.get(project.projectKey) ?? []).filter( |
| 2582 | 2774 | (thread) => thread.archivedAt === null, |
| 2583 | 2775 | ), |
| 2584 | appSettings.sidebarThreadSortOrder, | |
| 2776 | sidebarThreadSortOrder, | |
| 2585 | 2777 | ); |
| 2586 | 2778 | const projectExpanded = projectExpandedById[project.projectKey] ?? true; |
| 2587 | 2779 | const activeThreadKey = routeThreadKey ?? undefined; |
| ... | ... | @@ -2609,7 +2801,7 @@ export default function Sidebar() { |
| 2609 | 2801 | ); |
| 2610 | 2802 | }), |
| 2611 | 2803 | [ |
| 2612 | appSettings.sidebarThreadSortOrder, | |
| 2804 | sidebarThreadSortOrder, | |
| 2613 | 2805 | expandedThreadListsByProject, |
| 2614 | 2806 | projectExpandedById, |
| 2615 | 2807 | routeThreadKey, |
| ... | ... | @@ -2910,8 +3102,8 @@ export default function Sidebar() { |
| 2910 | 3102 | desktopUpdateButtonAction={desktopUpdateButtonAction} |
| 2911 | 3103 | desktopUpdateButtonDisabled={desktopUpdateButtonDisabled} |
| 2912 | 3104 | handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} |
| 2913 | projectSortOrder={appSettings.sidebarProjectSortOrder} | |
| 2914 | threadSortOrder={appSettings.sidebarThreadSortOrder} | |
| 3105 | projectSortOrder={sidebarProjectSortOrder} | |
| 3106 | threadSortOrder={sidebarThreadSortOrder} | |
| 2915 | 3107 | updateSettings={updateSettings} |
| 2916 | 3108 | shouldShowProjectPathEntry={shouldShowProjectPathEntry} |
| 2917 | 3109 | handleStartAddProject={handleStartAddProject} |
apps/web/src/components/SplashScreen.tsx created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | export function SplashScreen() { | |
| 2 | return ( | |
| 3 | <div className="flex min-h-screen items-center justify-center bg-background"> | |
| 4 | <div className="flex size-24 items-center justify-center" aria-label="T3 Code splash screen"> | |
| 5 | <img alt="T3 Code" className="size-16 object-contain" src="/apple-touch-icon.png" /> | |
| 6 | </div> | |
| 7 | </div> | |
| 8 | ); | |
| 9 | } |
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+45-3| ... | ... | @@ -141,11 +141,25 @@ function createEnvironmentApi() { |
| 141 | 141 | }; |
| 142 | 142 | } |
| 143 | 143 | |
| 144 | async function mountTerminalViewport(props: { threadRef: ReturnType<typeof scopeThreadRef> }) { | |
| 144 | async function mountTerminalViewport(props: { | |
| 145 | threadRef: ReturnType<typeof scopeThreadRef>; | |
| 146 | drawerBackgroundColor?: string; | |
| 147 | drawerTextColor?: string; | |
| 148 | }) { | |
| 149 | const drawer = document.createElement("div"); | |
| 150 | drawer.className = "thread-terminal-drawer"; | |
| 151 | if (props.drawerBackgroundColor) { | |
| 152 | drawer.style.backgroundColor = props.drawerBackgroundColor; | |
| 153 | } | |
| 154 | if (props.drawerTextColor) { | |
| 155 | drawer.style.color = props.drawerTextColor; | |
| 156 | } | |
| 157 | ||
| 145 | 158 | const host = document.createElement("div"); |
| 146 | 159 | host.style.width = "800px"; |
| 147 | 160 | host.style.height = "400px"; |
| 148 | document.body.append(host); | |
| 161 | drawer.append(host); | |
| 162 | document.body.append(drawer); | |
| 149 | 163 | |
| 150 | 164 | const screen = await render( |
| 151 | 165 | <TerminalViewport |
| ... | ... | @@ -184,7 +198,7 @@ async function mountTerminalViewport(props: { threadRef: ReturnType<typeof scope |
| 184 | 198 | }, |
| 185 | 199 | cleanup: async () => { |
| 186 | 200 | await screen.unmount(); |
| 187 | host.remove(); | |
| 201 | drawer.remove(); | |
| 188 | 202 | }, |
| 189 | 203 | }; |
| 190 | 204 | } |
| ... | ... | @@ -244,4 +258,32 @@ describe("TerminalViewport", () => { |
| 244 | 258 | await mounted.cleanup(); |
| 245 | 259 | } |
| 246 | 260 | }); |
| 261 | ||
| 262 | it("uses the drawer surface colors for the terminal theme", async () => { | |
| 263 | const environment = createEnvironmentApi(); | |
| 264 | environmentApiById.set("environment-a", environment); | |
| 265 | ||
| 266 | const mounted = await mountTerminalViewport({ | |
| 267 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | |
| 268 | drawerBackgroundColor: "rgb(24, 28, 36)", | |
| 269 | drawerTextColor: "rgb(228, 232, 240)", | |
| 270 | }); | |
| 271 | ||
| 272 | try { | |
| 273 | await vi.waitFor(() => { | |
| 274 | expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); | |
| 275 | }); | |
| 276 | ||
| 277 | expect(terminalConstructorSpy).toHaveBeenCalledWith( | |
| 278 | expect.objectContaining({ | |
| 279 | theme: expect.objectContaining({ | |
| 280 | background: "rgb(24, 28, 36)", | |
| 281 | foreground: "rgb(228, 232, 240)", | |
| 282 | }), | |
| 283 | }), | |
| 284 | ); | |
| 285 | } finally { | |
| 286 | await mounted.cleanup(); | |
| 287 | } | |
| 288 | }); | |
| 247 | 289 | }); |
apps/web/src/components/ThreadTerminalDrawer.tsx+35-7| ... | ... | @@ -76,12 +76,37 @@ export function selectPendingTerminalEventEntries( |
| 76 | 76 | return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | function terminalThemeFromApp(): ITheme { | |
| 79 | function normalizeComputedColor(value: string | null | undefined, fallback: string): string { | |
| 80 | const normalizedValue = value?.trim().toLowerCase(); | |
| 81 | if ( | |
| 82 | !normalizedValue || | |
| 83 | normalizedValue === "transparent" || | |
| 84 | normalizedValue === "rgba(0, 0, 0, 0)" || | |
| 85 | normalizedValue === "rgba(0 0 0 / 0)" | |
| 86 | ) { | |
| 87 | return fallback; | |
| 88 | } | |
| 89 | return value ?? fallback; | |
| 90 | } | |
| 91 | ||
| 92 | function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { | |
| 80 | 93 | const isDark = document.documentElement.classList.contains("dark"); |
| 94 | const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; | |
| 95 | const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; | |
| 96 | const drawerSurface = | |
| 97 | mountElement?.closest(".thread-terminal-drawer") ?? | |
| 98 | document.querySelector(".thread-terminal-drawer") ?? | |
| 99 | document.body; | |
| 100 | const drawerStyles = getComputedStyle(drawerSurface); | |
| 81 | 101 | const bodyStyles = getComputedStyle(document.body); |
| 82 | const background = | |
| 83 | bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"); | |
| 84 | const foreground = bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"); | |
| 102 | const background = normalizeComputedColor( | |
| 103 | drawerStyles.backgroundColor, | |
| 104 | normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), | |
| 105 | ); | |
| 106 | const foreground = normalizeComputedColor( | |
| 107 | drawerStyles.color, | |
| 108 | normalizeComputedColor(bodyStyles.color, fallbackForeground), | |
| 109 | ); | |
| 85 | 110 | |
| 86 | 111 | if (isDark) { |
| 87 | 112 | return { |
| ... | ... | @@ -275,7 +300,7 @@ export function TerminalViewport({ |
| 275 | 300 | fontSize: 12, |
| 276 | 301 | scrollback: 5_000, |
| 277 | 302 | fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', |
| 278 | theme: terminalThemeFromApp(), | |
| 303 | theme: terminalThemeFromApp(mount), | |
| 279 | 304 | }); |
| 280 | 305 | terminal.loadAddon(fitAddon); |
| 281 | 306 | terminal.open(mount); |
| ... | ... | @@ -488,7 +513,7 @@ export function TerminalViewport({ |
| 488 | 513 | const themeObserver = new MutationObserver(() => { |
| 489 | 514 | const activeTerminal = terminalRef.current; |
| 490 | 515 | if (!activeTerminal) return; |
| 491 | activeTerminal.options.theme = terminalThemeFromApp(); | |
| 516 | activeTerminal.options.theme = terminalThemeFromApp(containerRef.current); | |
| 492 | 517 | activeTerminal.refresh(0, activeTerminal.rows - 1); |
| 493 | 518 | }); |
| 494 | 519 | themeObserver.observe(document.documentElement, { |
| ... | ... | @@ -720,7 +745,10 @@ export function TerminalViewport({ |
| 720 | 745 | }; |
| 721 | 746 | }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]); |
| 722 | 747 | return ( |
| 723 | <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]" /> | |
| 748 | <div | |
| 749 | ref={containerRef} | |
| 750 | className="relative h-full w-full overflow-hidden rounded-[4px] bg-background" | |
| 751 | /> | |
| 724 | 752 | ); |
| 725 | 753 | } |
| 726 | 754 |
apps/web/src/components/WebSocketConnectionSurface.tsx+3-173| ... | ... | @@ -1,9 +1,6 @@ |
| 1 | import { AlertTriangle, CloudOff, LoaderCircle, RotateCw } from "lucide-react"; | |
| 2 | 1 | import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; |
| 3 | 2 | |
| 4 | import { APP_DISPLAY_NAME } from "../branding"; | |
| 5 | 3 | import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; |
| 6 | import { useServerConfig } from "../rpc/serverState"; | |
| 7 | 4 | import { |
| 8 | 5 | exhaustWsReconnectIfStillWaiting, |
| 9 | 6 | getWsConnectionStatus, |
| ... | ... | @@ -14,9 +11,8 @@ import { |
| 14 | 11 | useWsConnectionStatus, |
| 15 | 12 | WS_RECONNECT_MAX_ATTEMPTS, |
| 16 | 13 | } from "../rpc/wsConnectionState"; |
| 17 | import { Button } from "./ui/button"; | |
| 18 | 14 | import { toastManager } from "./ui/toast"; |
| 19 | import { getWsRpcClient } from "~/wsRpcClient"; | |
| 15 | import { getPrimaryEnvironmentConnection } from "../environments/runtime"; | |
| 20 | 16 | |
| 21 | 17 | const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; |
| 22 | 18 | type WsAutoReconnectTrigger = "focus" | "online"; |
| ... | ... | @@ -58,11 +54,7 @@ function describeExhaustedToast(): string { |
| 58 | 54 | return "Retries exhausted trying to reconnect"; |
| 59 | 55 | } |
| 60 | 56 | |
| 61 | function buildReconnectTitle(status: WsConnectionStatus): string { | |
| 62 | if (status.nextRetryAt === null) { | |
| 63 | return "Disconnected from T3 Server"; | |
| 64 | } | |
| 65 | ||
| 57 | function buildReconnectTitle(_status: WsConnectionStatus): string { | |
| 66 | 58 | return "Disconnected from T3 Server"; |
| 67 | 59 | } |
| 68 | 60 | |
| ... | ... | @@ -113,155 +105,6 @@ export function shouldAutoReconnect( |
| 113 | 105 | ); |
| 114 | 106 | } |
| 115 | 107 | |
| 116 | function buildBlockingCopy( | |
| 117 | uiState: WsConnectionUiState, | |
| 118 | status: WsConnectionStatus, | |
| 119 | ): { | |
| 120 | readonly description: string; | |
| 121 | readonly eyebrow: string; | |
| 122 | readonly title: string; | |
| 123 | } { | |
| 124 | if (uiState === "connecting") { | |
| 125 | return { | |
| 126 | description: `Opening the WebSocket connection to the ${APP_DISPLAY_NAME} server and waiting for the initial config snapshot.`, | |
| 127 | eyebrow: "Starting Session", | |
| 128 | title: `Connecting to ${APP_DISPLAY_NAME}`, | |
| 129 | }; | |
| 130 | } | |
| 131 | ||
| 132 | if (uiState === "offline") { | |
| 133 | return { | |
| 134 | description: | |
| 135 | "Your browser is offline, so the web client cannot reach the T3 server. Reconnect to the network and the app will retry automatically.", | |
| 136 | eyebrow: "Offline", | |
| 137 | title: "WebSocket connection unavailable", | |
| 138 | }; | |
| 139 | } | |
| 140 | ||
| 141 | if (status.lastError?.trim()) { | |
| 142 | return { | |
| 143 | description: `${status.lastError} Verify that the T3 server is running and reachable, then reload the app if needed.`, | |
| 144 | eyebrow: "Connection Error", | |
| 145 | title: "Cannot reach the T3 server", | |
| 146 | }; | |
| 147 | } | |
| 148 | ||
| 149 | return { | |
| 150 | description: | |
| 151 | "The web client could not complete its initial WebSocket connection to the T3 server. It will keep retrying in the background.", | |
| 152 | eyebrow: "Connection Error", | |
| 153 | title: "Cannot reach the T3 server", | |
| 154 | }; | |
| 155 | } | |
| 156 | ||
| 157 | function buildConnectionDetails(status: WsConnectionStatus, uiState: WsConnectionUiState): string { | |
| 158 | const details = [ | |
| 159 | `state: ${uiState}`, | |
| 160 | `online: ${status.online ? "yes" : "no"}`, | |
| 161 | `attempts: ${status.attemptCount}`, | |
| 162 | ]; | |
| 163 | ||
| 164 | if (status.socketUrl) { | |
| 165 | details.push(`socket: ${status.socketUrl}`); | |
| 166 | } | |
| 167 | if (status.connectedAt) { | |
| 168 | details.push(`connectedAt: ${status.connectedAt}`); | |
| 169 | } | |
| 170 | if (status.disconnectedAt) { | |
| 171 | details.push(`disconnectedAt: ${status.disconnectedAt}`); | |
| 172 | } | |
| 173 | if (status.lastErrorAt) { | |
| 174 | details.push(`lastErrorAt: ${status.lastErrorAt}`); | |
| 175 | } | |
| 176 | if (status.lastError) { | |
| 177 | details.push(`lastError: ${status.lastError}`); | |
| 178 | } | |
| 179 | if (status.closeCode !== null) { | |
| 180 | details.push(`closeCode: ${status.closeCode}`); | |
| 181 | } | |
| 182 | if (status.closeReason) { | |
| 183 | details.push(`closeReason: ${status.closeReason}`); | |
| 184 | } | |
| 185 | ||
| 186 | return details.join("\n"); | |
| 187 | } | |
| 188 | ||
| 189 | function WebSocketBlockingState({ | |
| 190 | status, | |
| 191 | uiState, | |
| 192 | }: { | |
| 193 | readonly status: WsConnectionStatus; | |
| 194 | readonly uiState: WsConnectionUiState; | |
| 195 | }) { | |
| 196 | const copy = buildBlockingCopy(uiState, status); | |
| 197 | const disconnectedAt = formatConnectionMoment(status.disconnectedAt ?? status.lastErrorAt); | |
| 198 | const Icon = | |
| 199 | uiState === "connecting" ? LoaderCircle : uiState === "offline" ? CloudOff : AlertTriangle; | |
| 200 | ||
| 201 | return ( | |
| 202 | <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6"> | |
| 203 | <div className="pointer-events-none absolute inset-0 opacity-90"> | |
| 204 | <div className="absolute inset-x-0 top-0 h-56 bg-[radial-gradient(48rem_18rem_at_top,color-mix(in_srgb,var(--color-amber-500)_16%,transparent),transparent)]" /> | |
| 205 | <div className="absolute inset-0 bg-[linear-gradient(145deg,color-mix(in_srgb,var(--background)_92%,var(--color-black))_0%,var(--background)_56%)]" /> | |
| 206 | </div> | |
| 207 | ||
| 208 | <section className="relative w-full max-w-xl rounded-[1.75rem] border border-border/80 bg-card/92 p-6 shadow-2xl shadow-black/20 backdrop-blur-md sm:p-8"> | |
| 209 | <div className="flex items-start justify-between gap-4"> | |
| 210 | <div> | |
| 211 | <p className="text-[11px] font-semibold tracking-[0.18em] text-muted-foreground uppercase"> | |
| 212 | {copy.eyebrow} | |
| 213 | </p> | |
| 214 | <h1 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">{copy.title}</h1> | |
| 215 | </div> | |
| 216 | <div className="rounded-2xl border border-border/70 bg-background/80 p-3 text-foreground shadow-sm"> | |
| 217 | <Icon className={uiState === "connecting" ? "size-5 animate-spin" : "size-5"} /> | |
| 218 | </div> | |
| 219 | </div> | |
| 220 | ||
| 221 | <p className="mt-3 text-sm leading-relaxed text-muted-foreground">{copy.description}</p> | |
| 222 | ||
| 223 | <div className="mt-5 grid gap-3 rounded-2xl border border-border/70 bg-background/60 p-4 text-sm sm:grid-cols-2"> | |
| 224 | <div> | |
| 225 | <p className="text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase"> | |
| 226 | Connection | |
| 227 | </p> | |
| 228 | <p className="mt-1 font-medium text-foreground"> | |
| 229 | {uiState === "connecting" | |
| 230 | ? "Opening WebSocket" | |
| 231 | : uiState === "offline" | |
| 232 | ? "Waiting for network" | |
| 233 | : "Retrying server connection"} | |
| 234 | </p> | |
| 235 | </div> | |
| 236 | <div> | |
| 237 | <p className="text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase"> | |
| 238 | Latest Event | |
| 239 | </p> | |
| 240 | <p className="mt-1 font-medium text-foreground">{disconnectedAt ?? "Pending"}</p> | |
| 241 | </div> | |
| 242 | </div> | |
| 243 | ||
| 244 | <div className="mt-5 flex flex-wrap gap-2"> | |
| 245 | <Button size="sm" onClick={() => window.location.reload()}> | |
| 246 | <RotateCw /> | |
| 247 | Reload app | |
| 248 | </Button> | |
| 249 | </div> | |
| 250 | ||
| 251 | <details className="group mt-5 overflow-hidden rounded-lg border border-border/70 bg-background/55"> | |
| 252 | <summary className="cursor-pointer list-none px-3 py-2 text-xs font-medium text-muted-foreground"> | |
| 253 | <span className="group-open:hidden">Show connection details</span> | |
| 254 | <span className="hidden group-open:inline">Hide connection details</span> | |
| 255 | </summary> | |
| 256 | <pre className="max-h-56 overflow-auto border-t border-border/70 bg-background/80 px-3 py-2 text-xs text-foreground/85"> | |
| 257 | {buildConnectionDetails(status, uiState)} | |
| 258 | </pre> | |
| 259 | </details> | |
| 260 | </section> | |
| 261 | </div> | |
| 262 | ); | |
| 263 | } | |
| 264 | ||
| 265 | 108 | export function WebSocketConnectionCoordinator() { |
| 266 | 109 | const status = useWsConnectionStatus(); |
| 267 | 110 | const [nowMs, setNowMs] = useState(() => Date.now()); |
| ... | ... | @@ -277,7 +120,7 @@ export function WebSocketConnectionCoordinator() { |
| 277 | 120 | toastResetTimerRef.current = null; |
| 278 | 121 | } |
| 279 | 122 | lastForcedReconnectAtRef.current = Date.now(); |
| 280 | void getWsRpcClient() | |
| 123 | void getPrimaryEnvironmentConnection() | |
| 281 | 124 | .reconnect() |
| 282 | 125 | .catch((error) => { |
| 283 | 126 | if (!showFailureToast) { |
| ... | ... | @@ -527,18 +370,5 @@ export function SlowRpcAckToastCoordinator() { |
| 527 | 370 | } |
| 528 | 371 | |
| 529 | 372 | export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { |
| 530 | const serverConfig = useServerConfig(); | |
| 531 | const status = useWsConnectionStatus(); | |
| 532 | ||
| 533 | if (serverConfig === null) { | |
| 534 | const uiState = getWsConnectionUiState(status); | |
| 535 | return ( | |
| 536 | <WebSocketBlockingState | |
| 537 | status={status} | |
| 538 | uiState={uiState === "connected" ? "connecting" : uiState} | |
| 539 | /> | |
| 540 | ); | |
| 541 | } | |
| 542 | ||
| 543 | 373 | return children; |
| 544 | 374 | } |
apps/web/src/components/auth/PairingRouteSurface.tsx created+195| ... | ... | @@ -0,0 +1,195 @@ |
| 1 | import type { AuthSessionState } from "@t3tools/contracts"; | |
| 2 | import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; | |
| 3 | ||
| 4 | import { APP_DISPLAY_NAME } from "../../branding"; | |
| 5 | import { | |
| 6 | peekPairingTokenFromUrl, | |
| 7 | stripPairingTokenFromUrl, | |
| 8 | submitServerAuthCredential, | |
| 9 | } from "../../environments/primary"; | |
| 10 | import { Button } from "../ui/button"; | |
| 11 | import { Input } from "../ui/input"; | |
| 12 | ||
| 13 | export function PairingPendingSurface() { | |
| 14 | return ( | |
| 15 | <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6"> | |
| 16 | <div className="pointer-events-none absolute inset-0 opacity-80"> | |
| 17 | <div className="absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-emerald-500)_14%,transparent),transparent)]" /> | |
| 18 | <div className="absolute inset-y-0 left-0 w-72 bg-[radial-gradient(28rem_18rem_at_left,color-mix(in_srgb,var(--color-sky-500)_10%,transparent),transparent)]" /> | |
| 19 | <div className="absolute inset-0 bg-[linear-gradient(145deg,color-mix(in_srgb,var(--background)_90%,var(--color-black))_0%,var(--background)_55%)]" /> | |
| 20 | </div> | |
| 21 | ||
| 22 | <section className="relative w-full max-w-xl rounded-2xl border border-border/80 bg-card/90 p-6 shadow-2xl shadow-black/20 backdrop-blur-md sm:p-8"> | |
| 23 | <p className="text-[11px] font-semibold tracking-[0.18em] text-muted-foreground uppercase"> | |
| 24 | {APP_DISPLAY_NAME} | |
| 25 | </p> | |
| 26 | <h1 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl"> | |
| 27 | Pairing with this environment | |
| 28 | </h1> | |
| 29 | <p className="mt-2 text-sm leading-relaxed text-muted-foreground"> | |
| 30 | Validating the pairing link and preparing your session. | |
| 31 | </p> | |
| 32 | </section> | |
| 33 | </div> | |
| 34 | ); | |
| 35 | } | |
| 36 | ||
| 37 | export function PairingRouteSurface({ | |
| 38 | auth, | |
| 39 | initialErrorMessage, | |
| 40 | onAuthenticated, | |
| 41 | }: { | |
| 42 | auth: AuthSessionState["auth"]; | |
| 43 | initialErrorMessage?: string; | |
| 44 | onAuthenticated: () => void; | |
| 45 | }) { | |
| 46 | const autoPairTokenRef = useRef<string | null>(peekPairingTokenFromUrl()); | |
| 47 | const [credential, setCredential] = useState(() => autoPairTokenRef.current ?? ""); | |
| 48 | const [errorMessage, setErrorMessage] = useState(initialErrorMessage ?? ""); | |
| 49 | const [isSubmitting, setIsSubmitting] = useState(false); | |
| 50 | const autoSubmitAttemptedRef = useRef(false); | |
| 51 | ||
| 52 | const submitCredential = useCallback( | |
| 53 | async (nextCredential: string) => { | |
| 54 | setIsSubmitting(true); | |
| 55 | setErrorMessage(""); | |
| 56 | ||
| 57 | const submitError = await submitServerAuthCredential(nextCredential).then( | |
| 58 | () => null, | |
| 59 | (error) => errorMessageFromUnknown(error), | |
| 60 | ); | |
| 61 | ||
| 62 | setIsSubmitting(false); | |
| 63 | ||
| 64 | if (submitError) { | |
| 65 | setErrorMessage(submitError); | |
| 66 | return; | |
| 67 | } | |
| 68 | ||
| 69 | startTransition(() => { | |
| 70 | onAuthenticated(); | |
| 71 | }); | |
| 72 | }, | |
| 73 | [onAuthenticated], | |
| 74 | ); | |
| 75 | ||
| 76 | const handleSubmit = useCallback( | |
| 77 | async (event?: React.SubmitEvent<HTMLFormElement>) => { | |
| 78 | event?.preventDefault(); | |
| 79 | await submitCredential(credential); | |
| 80 | }, | |
| 81 | [submitCredential, credential], | |
| 82 | ); | |
| 83 | ||
| 84 | useEffect(() => { | |
| 85 | const token = autoPairTokenRef.current; | |
| 86 | if (!token || autoSubmitAttemptedRef.current) { | |
| 87 | return; | |
| 88 | } | |
| 89 | ||
| 90 | autoSubmitAttemptedRef.current = true; | |
| 91 | stripPairingTokenFromUrl(); | |
| 92 | void submitCredential(token); | |
| 93 | }, [submitCredential]); | |
| 94 | ||
| 95 | return ( | |
| 96 | <div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6"> | |
| 97 | <div className="pointer-events-none absolute inset-0 opacity-80"> | |
| 98 | <div className="absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-emerald-500)_14%,transparent),transparent)]" /> | |
| 99 | <div className="absolute inset-y-0 left-0 w-72 bg-[radial-gradient(28rem_18rem_at_left,color-mix(in_srgb,var(--color-sky-500)_10%,transparent),transparent)]" /> | |
| 100 | <div className="absolute inset-0 bg-[linear-gradient(145deg,color-mix(in_srgb,var(--background)_90%,var(--color-black))_0%,var(--background)_55%)]" /> | |
| 101 | </div> | |
| 102 | ||
| 103 | <section className="relative w-full max-w-xl rounded-2xl border border-border/80 bg-card/90 p-6 shadow-2xl shadow-black/20 backdrop-blur-md sm:p-8"> | |
| 104 | <p className="text-[11px] font-semibold tracking-[0.18em] text-muted-foreground uppercase"> | |
| 105 | {APP_DISPLAY_NAME} | |
| 106 | </p> | |
| 107 | <h1 className="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl"> | |
| 108 | Pair with this environment | |
| 109 | </h1> | |
| 110 | <p className="mt-2 text-sm leading-relaxed text-muted-foreground"> | |
| 111 | {describeAuthGate(auth.bootstrapMethods)} | |
| 112 | </p> | |
| 113 | ||
| 114 | <form className="mt-6 space-y-4" onSubmit={(event) => void handleSubmit(event)}> | |
| 115 | <div className="space-y-2"> | |
| 116 | <label className="text-sm font-medium" htmlFor="pairing-token"> | |
| 117 | Pairing token | |
| 118 | </label> | |
| 119 | <Input | |
| 120 | id="pairing-token" | |
| 121 | autoCapitalize="none" | |
| 122 | autoComplete="off" | |
| 123 | autoCorrect="off" | |
| 124 | disabled={isSubmitting} | |
| 125 | nativeInput | |
| 126 | onChange={(event) => setCredential(event.currentTarget.value)} | |
| 127 | placeholder="Paste a one-time token or pairing secret" | |
| 128 | spellCheck={false} | |
| 129 | value={credential} | |
| 130 | /> | |
| 131 | </div> | |
| 132 | ||
| 133 | {errorMessage ? ( | |
| 134 | <div className="rounded-lg border border-destructive/30 bg-destructive/6 px-3 py-2 text-sm text-destructive"> | |
| 135 | {errorMessage} | |
| 136 | </div> | |
| 137 | ) : null} | |
| 138 | ||
| 139 | <div className="flex flex-wrap gap-2"> | |
| 140 | <Button disabled={isSubmitting} size="sm" type="submit"> | |
| 141 | {isSubmitting ? "Pairing..." : "Continue"} | |
| 142 | </Button> | |
| 143 | <Button | |
| 144 | disabled={isSubmitting} | |
| 145 | onClick={() => window.location.reload()} | |
| 146 | size="sm" | |
| 147 | variant="outline" | |
| 148 | > | |
| 149 | Reload app | |
| 150 | </Button> | |
| 151 | </div> | |
| 152 | </form> | |
| 153 | ||
| 154 | <div className="mt-6 rounded-lg border border-border/70 bg-background/55 px-3 py-3 text-xs leading-relaxed text-muted-foreground"> | |
| 155 | {describeSupportedMethods(auth.bootstrapMethods)} | |
| 156 | </div> | |
| 157 | </section> | |
| 158 | </div> | |
| 159 | ); | |
| 160 | } | |
| 161 | ||
| 162 | function errorMessageFromUnknown(error: unknown): string { | |
| 163 | if (error instanceof Error && error.message.trim().length > 0) { | |
| 164 | return error.message; | |
| 165 | } | |
| 166 | ||
| 167 | if (typeof error === "string" && error.trim().length > 0) { | |
| 168 | return error; | |
| 169 | } | |
| 170 | ||
| 171 | return "Authentication failed."; | |
| 172 | } | |
| 173 | ||
| 174 | function describeAuthGate(bootstrapMethods: ReadonlyArray<string>): string { | |
| 175 | if (bootstrapMethods.includes("desktop-bootstrap")) { | |
| 176 | return "This environment expects a trusted pairing credential before the app can connect."; | |
| 177 | } | |
| 178 | ||
| 179 | return "Enter a pairing token to start a session with this environment."; | |
| 180 | } | |
| 181 | ||
| 182 | function describeSupportedMethods(bootstrapMethods: ReadonlyArray<string>): string { | |
| 183 | if ( | |
| 184 | bootstrapMethods.includes("desktop-bootstrap") && | |
| 185 | bootstrapMethods.includes("one-time-token") | |
| 186 | ) { | |
| 187 | return "Desktop-managed pairing and one-time pairing tokens are both accepted for this environment."; | |
| 188 | } | |
| 189 | ||
| 190 | if (bootstrapMethods.includes("desktop-bootstrap")) { | |
| 191 | return "This environment is desktop-managed. Open it from the desktop app or paste a bootstrap credential if one was issued explicitly."; | |
| 192 | } | |
| 193 | ||
| 194 | return "This environment accepts one-time pairing tokens. Pairing links can open this page directly, or you can paste the token here."; | |
| 195 | } |
apps/web/src/components/settings/ConnectionsSettings.tsx created+1441| ... | ... | @@ -0,0 +1,1441 @@ |
| 1 | import { PlusIcon, QrCodeIcon } from "lucide-react"; | |
| 2 | import { memo, useCallback, useEffect, useMemo, useState } from "react"; | |
| 3 | import { | |
| 4 | type AuthClientSession, | |
| 5 | type AuthPairingLink, | |
| 6 | type DesktopServerExposureState, | |
| 7 | type EnvironmentId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { DateTime } from "effect"; | |
| 10 | ||
| 11 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 12 | import { cn } from "../../lib/utils"; | |
| 13 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; | |
| 14 | import { | |
| 15 | SettingsPageContainer, | |
| 16 | SettingsRow, | |
| 17 | SettingsSection, | |
| 18 | useRelativeTimeTick, | |
| 19 | } from "./settingsLayout"; | |
| 20 | import { Input } from "../ui/input"; | |
| 21 | import { | |
| 22 | Dialog, | |
| 23 | DialogFooter, | |
| 24 | DialogDescription, | |
| 25 | DialogHeader, | |
| 26 | DialogPanel, | |
| 27 | DialogPopup, | |
| 28 | DialogTitle, | |
| 29 | DialogTrigger, | |
| 30 | } from "../ui/dialog"; | |
| 31 | import { | |
| 32 | AlertDialog, | |
| 33 | AlertDialogClose, | |
| 34 | AlertDialogDescription, | |
| 35 | AlertDialogFooter, | |
| 36 | AlertDialogHeader, | |
| 37 | AlertDialogPopup, | |
| 38 | AlertDialogTitle, | |
| 39 | } from "../ui/alert-dialog"; | |
| 40 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | |
| 41 | import { QRCodeSvg } from "../ui/qr-code"; | |
| 42 | import { Spinner } from "../ui/spinner"; | |
| 43 | import { Switch } from "../ui/switch"; | |
| 44 | import { toastManager } from "../ui/toast"; | |
| 45 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 46 | import { Button } from "../ui/button"; | |
| 47 | import { Textarea } from "../ui/textarea"; | |
| 48 | import { setPairingTokenOnUrl } from "../../pairingUrl"; | |
| 49 | import { | |
| 50 | createServerPairingCredential, | |
| 51 | fetchSessionState, | |
| 52 | revokeOtherServerClientSessions, | |
| 53 | revokeServerClientSession, | |
| 54 | revokeServerPairingLink, | |
| 55 | type ServerClientSessionRecord, | |
| 56 | type ServerPairingLinkRecord, | |
| 57 | } from "../../environments/primary"; | |
| 58 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | |
| 59 | import { | |
| 60 | type SavedEnvironmentRecord, | |
| 61 | type SavedEnvironmentRuntimeState, | |
| 62 | useSavedEnvironmentRegistryStore, | |
| 63 | useSavedEnvironmentRuntimeStore, | |
| 64 | addSavedEnvironment, | |
| 65 | getPrimaryEnvironmentConnection, | |
| 66 | reconnectSavedEnvironment, | |
| 67 | removeSavedEnvironment, | |
| 68 | } from "../../environments/runtime"; | |
| 69 | ||
| 70 | const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { | |
| 71 | dateStyle: "medium", | |
| 72 | timeStyle: "short", | |
| 73 | }); | |
| 74 | ||
| 75 | function formatAccessTimestamp(value: string): string { | |
| 76 | const parsed = new Date(value); | |
| 77 | if (Number.isNaN(parsed.getTime())) { | |
| 78 | return value; | |
| 79 | } | |
| 80 | return accessTimestampFormatter.format(parsed); | |
| 81 | } | |
| 82 | ||
| 83 | type ConnectionStatusDotProps = { | |
| 84 | tooltipText?: string | null; | |
| 85 | dotClassName: string; | |
| 86 | pingClassName?: string | null; | |
| 87 | }; | |
| 88 | ||
| 89 | function ConnectionStatusDot({ | |
| 90 | tooltipText, | |
| 91 | dotClassName, | |
| 92 | pingClassName, | |
| 93 | }: ConnectionStatusDotProps) { | |
| 94 | const dotContent = ( | |
| 95 | <> | |
| 96 | {pingClassName ? ( | |
| 97 | <span | |
| 98 | className={cn( | |
| 99 | "absolute inline-flex h-full w-full animate-ping rounded-full", | |
| 100 | pingClassName, | |
| 101 | )} | |
| 102 | /> | |
| 103 | ) : null} | |
| 104 | <span className={cn("relative inline-flex size-2 rounded-full", dotClassName)} /> | |
| 105 | </> | |
| 106 | ); | |
| 107 | ||
| 108 | if (!tooltipText) { | |
| 109 | return ( | |
| 110 | <span className="relative flex size-3 shrink-0 items-center justify-center"> | |
| 111 | {dotContent} | |
| 112 | </span> | |
| 113 | ); | |
| 114 | } | |
| 115 | ||
| 116 | const dot = ( | |
| 117 | <button | |
| 118 | type="button" | |
| 119 | title={tooltipText} | |
| 120 | aria-label={tooltipText} | |
| 121 | className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden" | |
| 122 | > | |
| 123 | {dotContent} | |
| 124 | </button> | |
| 125 | ); | |
| 126 | ||
| 127 | return ( | |
| 128 | <Tooltip> | |
| 129 | <TooltipTrigger render={dot} /> | |
| 130 | <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight"> | |
| 131 | {tooltipText} | |
| 132 | </TooltipPopup> | |
| 133 | </Tooltip> | |
| 134 | ); | |
| 135 | } | |
| 136 | ||
| 137 | function getSavedBackendStatusTooltip( | |
| 138 | runtime: SavedEnvironmentRuntimeState | null, | |
| 139 | record: SavedEnvironmentRecord, | |
| 140 | nowMs: number, | |
| 141 | ) { | |
| 142 | const connectionState = runtime?.connectionState ?? "disconnected"; | |
| 143 | ||
| 144 | if (connectionState === "connected") { | |
| 145 | const connectedAt = runtime?.connectedAt ?? record.lastConnectedAt; | |
| 146 | return connectedAt ? `Connected for ${formatElapsedDurationLabel(connectedAt, nowMs)}` : null; | |
| 147 | } | |
| 148 | ||
| 149 | if (connectionState === "connecting") { | |
| 150 | return null; | |
| 151 | } | |
| 152 | ||
| 153 | if (connectionState === "error") { | |
| 154 | return runtime?.lastError ?? "An unknown connection error occurred."; | |
| 155 | } | |
| 156 | ||
| 157 | return record.lastConnectedAt | |
| 158 | ? `Last connected at ${formatAccessTimestamp(record.lastConnectedAt)}` | |
| 159 | : "Not connected yet."; | |
| 160 | } | |
| 161 | ||
| 162 | /** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ | |
| 163 | const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; | |
| 164 | ||
| 165 | const ITEM_ROW_INNER_CLASSNAME = | |
| 166 | "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; | |
| 167 | ||
| 168 | function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) { | |
| 169 | return [...links].toSorted( | |
| 170 | (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), | |
| 171 | ); | |
| 172 | } | |
| 173 | ||
| 174 | function sortDesktopClientSessions(sessions: ReadonlyArray<ServerClientSessionRecord>) { | |
| 175 | return [...sessions].toSorted((left, right) => { | |
| 176 | if (left.current !== right.current) { | |
| 177 | return left.current ? -1 : 1; | |
| 178 | } | |
| 179 | if (left.connected !== right.connected) { | |
| 180 | return left.connected ? -1 : 1; | |
| 181 | } | |
| 182 | return new Date(right.issuedAt).getTime() - new Date(left.issuedAt).getTime(); | |
| 183 | }); | |
| 184 | } | |
| 185 | ||
| 186 | function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord { | |
| 187 | return { | |
| 188 | ...pairingLink, | |
| 189 | createdAt: DateTime.formatIso(pairingLink.createdAt), | |
| 190 | expiresAt: DateTime.formatIso(pairingLink.expiresAt), | |
| 191 | }; | |
| 192 | } | |
| 193 | ||
| 194 | function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord { | |
| 195 | return { | |
| 196 | ...clientSession, | |
| 197 | issuedAt: DateTime.formatIso(clientSession.issuedAt), | |
| 198 | expiresAt: DateTime.formatIso(clientSession.expiresAt), | |
| 199 | lastConnectedAt: | |
| 200 | clientSession.lastConnectedAt === null | |
| 201 | ? null | |
| 202 | : DateTime.formatIso(clientSession.lastConnectedAt), | |
| 203 | }; | |
| 204 | } | |
| 205 | ||
| 206 | function upsertDesktopPairingLink( | |
| 207 | current: ReadonlyArray<ServerPairingLinkRecord>, | |
| 208 | next: ServerPairingLinkRecord, | |
| 209 | ) { | |
| 210 | const existingIndex = current.findIndex((pairingLink) => pairingLink.id === next.id); | |
| 211 | if (existingIndex === -1) { | |
| 212 | return sortDesktopPairingLinks([...current, next]); | |
| 213 | } | |
| 214 | const updated = [...current]; | |
| 215 | updated[existingIndex] = next; | |
| 216 | return sortDesktopPairingLinks(updated); | |
| 217 | } | |
| 218 | ||
| 219 | function removeDesktopPairingLink(current: ReadonlyArray<ServerPairingLinkRecord>, id: string) { | |
| 220 | return current.filter((pairingLink) => pairingLink.id !== id); | |
| 221 | } | |
| 222 | ||
| 223 | function upsertDesktopClientSession( | |
| 224 | current: ReadonlyArray<ServerClientSessionRecord>, | |
| 225 | next: ServerClientSessionRecord, | |
| 226 | ) { | |
| 227 | const existingIndex = current.findIndex( | |
| 228 | (clientSession) => clientSession.sessionId === next.sessionId, | |
| 229 | ); | |
| 230 | if (existingIndex === -1) { | |
| 231 | return sortDesktopClientSessions([...current, next]); | |
| 232 | } | |
| 233 | const updated = [...current]; | |
| 234 | updated[existingIndex] = next; | |
| 235 | return sortDesktopClientSessions(updated); | |
| 236 | } | |
| 237 | ||
| 238 | function removeDesktopClientSession( | |
| 239 | current: ReadonlyArray<ServerClientSessionRecord>, | |
| 240 | sessionId: ServerClientSessionRecord["sessionId"], | |
| 241 | ) { | |
| 242 | return current.filter((clientSession) => clientSession.sessionId !== sessionId); | |
| 243 | } | |
| 244 | ||
| 245 | function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { | |
| 246 | const url = new URL(endpointUrl); | |
| 247 | url.pathname = "/pair"; | |
| 248 | return setPairingTokenOnUrl(url, credential).toString(); | |
| 249 | } | |
| 250 | ||
| 251 | function resolveCurrentOriginPairingUrl(credential: string): string { | |
| 252 | const url = new URL("/pair", window.location.href); | |
| 253 | return setPairingTokenOnUrl(url, credential).toString(); | |
| 254 | } | |
| 255 | ||
| 256 | function isLoopbackHostname(hostname: string): boolean { | |
| 257 | const normalized = hostname.trim().toLowerCase(); | |
| 258 | return ( | |
| 259 | normalized === "localhost" || | |
| 260 | normalized === "127.0.0.1" || | |
| 261 | normalized === "::1" || | |
| 262 | normalized === "[::1]" | |
| 263 | ); | |
| 264 | } | |
| 265 | ||
| 266 | type PairingLinkListRowProps = { | |
| 267 | pairingLink: ServerPairingLinkRecord; | |
| 268 | endpointUrl: string | null | undefined; | |
| 269 | revokingPairingLinkId: string | null; | |
| 270 | onRevoke: (id: string) => void; | |
| 271 | }; | |
| 272 | ||
| 273 | const PairingLinkListRow = memo(function PairingLinkListRow({ | |
| 274 | pairingLink, | |
| 275 | endpointUrl, | |
| 276 | revokingPairingLinkId, | |
| 277 | onRevoke, | |
| 278 | }: PairingLinkListRowProps) { | |
| 279 | const nowMs = useRelativeTimeTick(1_000); | |
| 280 | const expiresAtMs = useMemo( | |
| 281 | () => new Date(pairingLink.expiresAt).getTime(), | |
| 282 | [pairingLink.expiresAt], | |
| 283 | ); | |
| 284 | const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false); | |
| 285 | ||
| 286 | const currentOriginPairingUrl = useMemo( | |
| 287 | () => resolveCurrentOriginPairingUrl(pairingLink.credential), | |
| 288 | [pairingLink.credential], | |
| 289 | ); | |
| 290 | const shareablePairingUrl = | |
| 291 | endpointUrl != null && endpointUrl !== "" | |
| 292 | ? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential) | |
| 293 | : isLoopbackHostname(window.location.hostname) | |
| 294 | ? null | |
| 295 | : currentOriginPairingUrl; | |
| 296 | const copyValue = shareablePairingUrl ?? pairingLink.credential; | |
| 297 | const canCopyToClipboard = | |
| 298 | typeof window !== "undefined" && | |
| 299 | window.isSecureContext && | |
| 300 | navigator.clipboard?.writeText != null; | |
| 301 | ||
| 302 | const { copyToClipboard, isCopied } = useCopyToClipboard({ | |
| 303 | onCopy: () => { | |
| 304 | toastManager.add({ | |
| 305 | type: "success", | |
| 306 | title: shareablePairingUrl ? "Pairing URL copied" : "Pairing token copied", | |
| 307 | description: shareablePairingUrl | |
| 308 | ? "Open it in the client you want to pair to this environment." | |
| 309 | : "Paste it into another client with this backend's reachable host.", | |
| 310 | }); | |
| 311 | }, | |
| 312 | onError: (error) => { | |
| 313 | setIsRevealDialogOpen(true); | |
| 314 | toastManager.add({ | |
| 315 | type: "error", | |
| 316 | title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable", | |
| 317 | description: canCopyToClipboard ? error.message : "Showing the full value instead.", | |
| 318 | }); | |
| 319 | }, | |
| 320 | }); | |
| 321 | ||
| 322 | const handleCopy = useCallback(() => { | |
| 323 | copyToClipboard(copyValue, undefined); | |
| 324 | }, [copyToClipboard, copyValue]); | |
| 325 | ||
| 326 | const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); | |
| 327 | ||
| 328 | const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client"; | |
| 329 | const primaryLabel = pairingLink.label ?? `${roleLabel} link`; | |
| 330 | ||
| 331 | if (expiresAtMs <= nowMs) { | |
| 332 | return null; | |
| 333 | } | |
| 334 | ||
| 335 | return ( | |
| 336 | <div className={ITEM_ROW_CLASSNAME}> | |
| 337 | <div className={ITEM_ROW_INNER_CLASSNAME}> | |
| 338 | <div className="min-w-0 flex-1 space-y-1"> | |
| 339 | <div className="flex min-h-5 items-center gap-1.5"> | |
| 340 | <ConnectionStatusDot | |
| 341 | tooltipText={`Link created at ${formatAccessTimestamp(pairingLink.createdAt)}`} | |
| 342 | dotClassName="bg-amber-400" | |
| 343 | /> | |
| 344 | <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3> | |
| 345 | <Popover> | |
| 346 | {shareablePairingUrl ? ( | |
| 347 | <> | |
| 348 | <PopoverTrigger | |
| 349 | openOnHover | |
| 350 | delay={250} | |
| 351 | closeDelay={100} | |
| 352 | render={ | |
| 353 | <button | |
| 354 | type="button" | |
| 355 | className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground/50 outline-none hover:text-foreground" | |
| 356 | aria-label="Show QR code" | |
| 357 | /> | |
| 358 | } | |
| 359 | > | |
| 360 | <QrCodeIcon aria-hidden className="size-3" /> | |
| 361 | </PopoverTrigger> | |
| 362 | <PopoverPopup side="top" align="start" tooltipStyle className="w-max"> | |
| 363 | <QRCodeSvg | |
| 364 | value={shareablePairingUrl} | |
| 365 | size={88} | |
| 366 | level="M" | |
| 367 | marginSize={2} | |
| 368 | title="Pairing link — scan to open on another device" | |
| 369 | /> | |
| 370 | </PopoverPopup> | |
| 371 | </> | |
| 372 | ) : null} | |
| 373 | </Popover> | |
| 374 | </div> | |
| 375 | <p className="text-xs text-muted-foreground" title={expiresAbsolute}> | |
| 376 | {[roleLabel, formatExpiresInLabel(pairingLink.expiresAt, nowMs)].join(" · ")} | |
| 377 | </p> | |
| 378 | {shareablePairingUrl === null ? ( | |
| 379 | <p className="text-[11px] text-muted-foreground/70"> | |
| 380 | Copy the token and pair from another client using this backend&apos;s reachable host. | |
| 381 | </p> | |
| 382 | ) : null} | |
| 383 | </div> | |
| 384 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | |
| 385 | <Dialog open={isRevealDialogOpen} onOpenChange={setIsRevealDialogOpen}> | |
| 386 | {canCopyToClipboard ? ( | |
| 387 | <Button size="xs" variant="outline" onClick={handleCopy}> | |
| 388 | {isCopied ? "Copied" : shareablePairingUrl ? "Copy" : "Copy token"} | |
| 389 | </Button> | |
| 390 | ) : ( | |
| 391 | <DialogTrigger render={<Button size="xs" variant="outline" />}> | |
| 392 | {shareablePairingUrl ? "Show link" : "Show token"} | |
| 393 | </DialogTrigger> | |
| 394 | )} | |
| 395 | <DialogPopup className="max-w-md"> | |
| 396 | <DialogHeader> | |
| 397 | <DialogTitle>{shareablePairingUrl ? "Pairing link" : "Pairing token"}</DialogTitle> | |
| 398 | <DialogDescription> | |
| 399 | {shareablePairingUrl | |
| 400 | ? "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." | |
| 401 | : "Clipboard copy is unavailable here. Manually copy this token and pair from another client using this backend's reachable host."} | |
| 402 | </DialogDescription> | |
| 403 | </DialogHeader> | |
| 404 | <DialogPanel className="space-y-4"> | |
| 405 | <Textarea | |
| 406 | readOnly | |
| 407 | value={copyValue} | |
| 408 | rows={shareablePairingUrl ? 4 : 3} | |
| 409 | className="text-xs leading-relaxed" | |
| 410 | onFocus={(event) => event.currentTarget.select()} | |
| 411 | onClick={(event) => event.currentTarget.select()} | |
| 412 | /> | |
| 413 | {shareablePairingUrl ? ( | |
| 414 | <div className="flex justify-center rounded-xl border border-border/60 bg-muted/30 p-4"> | |
| 415 | <QRCodeSvg | |
| 416 | value={shareablePairingUrl} | |
| 417 | size={132} | |
| 418 | level="M" | |
| 419 | marginSize={2} | |
| 420 | title="Pairing link — scan to open on another device" | |
| 421 | /> | |
| 422 | </div> | |
| 423 | ) : null} | |
| 424 | </DialogPanel> | |
| 425 | <DialogFooter variant="bare"> | |
| 426 | <Button variant="outline" onClick={() => setIsRevealDialogOpen(false)}> | |
| 427 | Done | |
| 428 | </Button> | |
| 429 | {canCopyToClipboard ? ( | |
| 430 | <Button variant="outline" size="xs" onClick={handleCopy}> | |
| 431 | {isCopied ? "Copied" : "Copy again"} | |
| 432 | </Button> | |
| 433 | ) : null} | |
| 434 | </DialogFooter> | |
| 435 | </DialogPopup> | |
| 436 | </Dialog> | |
| 437 | <Button | |
| 438 | size="xs" | |
| 439 | variant="destructive-outline" | |
| 440 | disabled={revokingPairingLinkId === pairingLink.id} | |
| 441 | onClick={() => void onRevoke(pairingLink.id)} | |
| 442 | > | |
| 443 | {revokingPairingLinkId === pairingLink.id ? "Revoking…" : "Revoke"} | |
| 444 | </Button> | |
| 445 | </div> | |
| 446 | </div> | |
| 447 | </div> | |
| 448 | ); | |
| 449 | }); | |
| 450 | ||
| 451 | type ConnectedClientListRowProps = { | |
| 452 | clientSession: ServerClientSessionRecord; | |
| 453 | revokingClientSessionId: string | null; | |
| 454 | onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; | |
| 455 | }; | |
| 456 | ||
| 457 | const ConnectedClientListRow = memo(function ConnectedClientListRow({ | |
| 458 | clientSession, | |
| 459 | revokingClientSessionId, | |
| 460 | onRevokeSession, | |
| 461 | }: ConnectedClientListRowProps) { | |
| 462 | const nowMs = useRelativeTimeTick(1_000); | |
| 463 | const isLive = clientSession.current || clientSession.connected; | |
| 464 | const lastConnectedAt = clientSession.lastConnectedAt; | |
| 465 | const statusTooltip = isLive | |
| 466 | ? lastConnectedAt | |
| 467 | ? `Connected for ${formatElapsedDurationLabel(lastConnectedAt, nowMs)}` | |
| 468 | : "Connected" | |
| 469 | : lastConnectedAt | |
| 470 | ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}` | |
| 471 | : "Not connected yet."; | |
| 472 | const roleLabel = clientSession.role === "owner" ? "Owner" : "Client"; | |
| 473 | const deviceInfoBits = [ | |
| 474 | clientSession.client.deviceType !== "unknown" | |
| 475 | ? clientSession.client.deviceType[0]?.toUpperCase() + clientSession.client.deviceType.slice(1) | |
| 476 | : null, | |
| 477 | clientSession.client.os ?? null, | |
| 478 | clientSession.client.browser ?? null, | |
| 479 | clientSession.client.ipAddress ?? null, | |
| 480 | ].filter((value): value is string => value !== null); | |
| 481 | const primaryLabel = | |
| 482 | clientSession.client.label ?? | |
| 483 | ([clientSession.client.os, clientSession.client.browser].filter(Boolean).join(" · ") || | |
| 484 | clientSession.subject); | |
| 485 | ||
| 486 | return ( | |
| 487 | <div className={ITEM_ROW_CLASSNAME}> | |
| 488 | <div className={ITEM_ROW_INNER_CLASSNAME}> | |
| 489 | <div className="min-w-0 flex-1 space-y-1"> | |
| 490 | <div className="flex min-h-5 items-center gap-1.5"> | |
| 491 | <ConnectionStatusDot | |
| 492 | tooltipText={statusTooltip} | |
| 493 | dotClassName={isLive ? "bg-success" : "bg-muted-foreground/30"} | |
| 494 | pingClassName={isLive ? "bg-success/60 duration-2000" : null} | |
| 495 | /> | |
| 496 | <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3> | |
| 497 | {clientSession.current ? ( | |
| 498 | <span className="text-[10px] text-muted-foreground/80 rounded-md border border-border/50 bg-muted/50 px-1 py-0.5"> | |
| 499 | This device | |
| 500 | </span> | |
| 501 | ) : null} | |
| 502 | </div> | |
| 503 | <p className="text-xs text-muted-foreground"> | |
| 504 | {[roleLabel, ...deviceInfoBits].join(" · ")} | |
| 505 | </p> | |
| 506 | </div> | |
| 507 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | |
| 508 | {!clientSession.current ? ( | |
| 509 | <Button | |
| 510 | size="xs" | |
| 511 | variant="destructive-outline" | |
| 512 | disabled={revokingClientSessionId === clientSession.sessionId} | |
| 513 | onClick={() => void onRevokeSession(clientSession.sessionId)} | |
| 514 | > | |
| 515 | {revokingClientSessionId === clientSession.sessionId ? "Revoking…" : "Revoke"} | |
| 516 | </Button> | |
| 517 | ) : null} | |
| 518 | </div> | |
| 519 | </div> | |
| 520 | </div> | |
| 521 | ); | |
| 522 | }); | |
| 523 | ||
| 524 | type AuthorizedClientsHeaderActionProps = { | |
| 525 | clientSessions: ReadonlyArray<ServerClientSessionRecord>; | |
| 526 | isRevokingOtherClients: boolean; | |
| 527 | onRevokeOtherClients: () => void; | |
| 528 | }; | |
| 529 | ||
| 530 | const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({ | |
| 531 | clientSessions, | |
| 532 | isRevokingOtherClients, | |
| 533 | onRevokeOtherClients, | |
| 534 | }: AuthorizedClientsHeaderActionProps) { | |
| 535 | const [dialogOpen, setDialogOpen] = useState(false); | |
| 536 | const [pairingLabel, setPairingLabel] = useState(""); | |
| 537 | const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false); | |
| 538 | ||
| 539 | const handleCreatePairingLink = useCallback(async () => { | |
| 540 | setIsCreatingPairingLink(true); | |
| 541 | try { | |
| 542 | await createServerPairingCredential(pairingLabel); | |
| 543 | setPairingLabel(""); | |
| 544 | setDialogOpen(false); | |
| 545 | } catch (error) { | |
| 546 | const message = error instanceof Error ? error.message : "Failed to create pairing URL."; | |
| 547 | toastManager.add({ | |
| 548 | type: "error", | |
| 549 | title: "Could not create pairing URL", | |
| 550 | description: message, | |
| 551 | }); | |
| 552 | } finally { | |
| 553 | setIsCreatingPairingLink(false); | |
| 554 | } | |
| 555 | }, [pairingLabel]); | |
| 556 | ||
| 557 | return ( | |
| 558 | <div className="flex items-center gap-2"> | |
| 559 | <Button | |
| 560 | size="xs" | |
| 561 | variant="destructive-outline" | |
| 562 | disabled={ | |
| 563 | isRevokingOtherClients || clientSessions.every((clientSession) => clientSession.current) | |
| 564 | } | |
| 565 | onClick={() => void onRevokeOtherClients()} | |
| 566 | > | |
| 567 | {isRevokingOtherClients ? "Revoking…" : "Revoke others"} | |
| 568 | </Button> | |
| 569 | <Dialog | |
| 570 | open={dialogOpen} | |
| 571 | onOpenChange={(open) => { | |
| 572 | setDialogOpen(open); | |
| 573 | if (!open) { | |
| 574 | setPairingLabel(""); | |
| 575 | } | |
| 576 | }} | |
| 577 | > | |
| 578 | <DialogTrigger | |
| 579 | render={ | |
| 580 | <Button size="xs" variant="default"> | |
| 581 | <PlusIcon className="size-3" /> | |
| 582 | Create link | |
| 583 | </Button> | |
| 584 | } | |
| 585 | /> | |
| 586 | <DialogPopup className="max-w-sm"> | |
| 587 | <DialogHeader> | |
| 588 | <DialogTitle>Create pairing link</DialogTitle> | |
| 589 | <DialogDescription> | |
| 590 | Generate a one-time link that another device can use to pair with this backend as an | |
| 591 | authorized client. | |
| 592 | </DialogDescription> | |
| 593 | </DialogHeader> | |
| 594 | <DialogPanel> | |
| 595 | <label className="block"> | |
| 596 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | |
| 597 | Client label (optional) | |
| 598 | </span> | |
| 599 | <Input | |
| 600 | value={pairingLabel} | |
| 601 | onChange={(event) => setPairingLabel(event.target.value)} | |
| 602 | placeholder="e.g. Living room iPad" | |
| 603 | disabled={isCreatingPairingLink} | |
| 604 | autoFocus | |
| 605 | /> | |
| 606 | </label> | |
| 607 | </DialogPanel> | |
| 608 | <DialogFooter variant="bare"> | |
| 609 | <Button | |
| 610 | variant="outline" | |
| 611 | disabled={isCreatingPairingLink} | |
| 612 | onClick={() => setDialogOpen(false)} | |
| 613 | > | |
| 614 | Cancel | |
| 615 | </Button> | |
| 616 | <Button disabled={isCreatingPairingLink} onClick={() => void handleCreatePairingLink()}> | |
| 617 | {isCreatingPairingLink ? "Creating…" : "Create link"} | |
| 618 | </Button> | |
| 619 | </DialogFooter> | |
| 620 | </DialogPopup> | |
| 621 | </Dialog> | |
| 622 | </div> | |
| 623 | ); | |
| 624 | }); | |
| 625 | ||
| 626 | type PairingClientsListProps = { | |
| 627 | endpointUrl: string | null | undefined; | |
| 628 | isLoading: boolean; | |
| 629 | pairingLinks: ReadonlyArray<ServerPairingLinkRecord>; | |
| 630 | clientSessions: ReadonlyArray<ServerClientSessionRecord>; | |
| 631 | revokingPairingLinkId: string | null; | |
| 632 | revokingClientSessionId: string | null; | |
| 633 | onRevokePairingLink: (id: string) => void; | |
| 634 | onRevokeClientSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; | |
| 635 | }; | |
| 636 | ||
| 637 | const PairingClientsList = memo(function PairingClientsList({ | |
| 638 | endpointUrl, | |
| 639 | isLoading, | |
| 640 | pairingLinks, | |
| 641 | clientSessions, | |
| 642 | revokingPairingLinkId, | |
| 643 | revokingClientSessionId, | |
| 644 | onRevokePairingLink, | |
| 645 | onRevokeClientSession, | |
| 646 | }: PairingClientsListProps) { | |
| 647 | return ( | |
| 648 | <> | |
| 649 | {pairingLinks.map((pairingLink) => ( | |
| 650 | <PairingLinkListRow | |
| 651 | key={pairingLink.id} | |
| 652 | pairingLink={pairingLink} | |
| 653 | endpointUrl={endpointUrl} | |
| 654 | revokingPairingLinkId={revokingPairingLinkId} | |
| 655 | onRevoke={onRevokePairingLink} | |
| 656 | /> | |
| 657 | ))} | |
| 658 | ||
| 659 | {clientSessions.map((clientSession) => ( | |
| 660 | <ConnectedClientListRow | |
| 661 | key={clientSession.sessionId} | |
| 662 | clientSession={clientSession} | |
| 663 | revokingClientSessionId={revokingClientSessionId} | |
| 664 | onRevokeSession={onRevokeClientSession} | |
| 665 | /> | |
| 666 | ))} | |
| 667 | ||
| 668 | {pairingLinks.length === 0 && clientSessions.length === 0 && !isLoading ? ( | |
| 669 | <div className={ITEM_ROW_CLASSNAME}> | |
| 670 | <p className="text-xs text-muted-foreground/60">No pairing links or client sessions.</p> | |
| 671 | </div> | |
| 672 | ) : null} | |
| 673 | </> | |
| 674 | ); | |
| 675 | }); | |
| 676 | ||
| 677 | type SavedBackendListRowProps = { | |
| 678 | environmentId: EnvironmentId; | |
| 679 | reconnectingEnvironmentId: EnvironmentId | null; | |
| 680 | removingEnvironmentId: EnvironmentId | null; | |
| 681 | onReconnect: (environmentId: EnvironmentId) => void; | |
| 682 | onRemove: (environmentId: EnvironmentId) => void; | |
| 683 | }; | |
| 684 | ||
| 685 | function SavedBackendListRow({ | |
| 686 | environmentId, | |
| 687 | reconnectingEnvironmentId, | |
| 688 | removingEnvironmentId, | |
| 689 | onReconnect, | |
| 690 | onRemove, | |
| 691 | }: SavedBackendListRowProps) { | |
| 692 | const nowMs = useRelativeTimeTick(1_000); | |
| 693 | const record = useSavedEnvironmentRegistryStore((state) => state.byId[environmentId] ?? null); | |
| 694 | const runtime = useSavedEnvironmentRuntimeStore((state) => state.byId[environmentId] ?? null); | |
| 695 | ||
| 696 | if (!record) { | |
| 697 | return null; | |
| 698 | } | |
| 699 | ||
| 700 | const connectionState = runtime?.connectionState ?? "disconnected"; | |
| 701 | const stateDotClassName = | |
| 702 | connectionState === "connected" | |
| 703 | ? "bg-success" | |
| 704 | : connectionState === "connecting" | |
| 705 | ? "bg-warning" | |
| 706 | : connectionState === "error" | |
| 707 | ? "bg-destructive" | |
| 708 | : "bg-muted-foreground/40"; | |
| 709 | const roleLabel = runtime?.role ? (runtime.role === "owner" ? "Owner" : "Client") : null; | |
| 710 | const descriptorLabel = runtime?.descriptor?.label ?? null; | |
| 711 | const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); | |
| 712 | const metadataBits = [ | |
| 713 | roleLabel, | |
| 714 | record.lastConnectedAt | |
| 715 | ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` | |
| 716 | : null, | |
| 717 | ].filter((value): value is string => value !== null); | |
| 718 | ||
| 719 | return ( | |
| 720 | <div className={ITEM_ROW_CLASSNAME}> | |
| 721 | <div className={ITEM_ROW_INNER_CLASSNAME}> | |
| 722 | <div className="min-w-0 flex-1 space-y-1"> | |
| 723 | <div className="flex min-h-5 items-center gap-1.5"> | |
| 724 | <ConnectionStatusDot | |
| 725 | tooltipText={statusTooltip} | |
| 726 | dotClassName={stateDotClassName} | |
| 727 | pingClassName={ | |
| 728 | connectionState === "connecting" ? "bg-warning/60 duration-2000" : null | |
| 729 | } | |
| 730 | /> | |
| 731 | <h3 className="text-sm font-medium text-foreground">{record.label}</h3> | |
| 732 | </div> | |
| 733 | {metadataBits.length > 0 ? ( | |
| 734 | <p className="text-xs text-muted-foreground">{metadataBits.join(" · ")}</p> | |
| 735 | ) : null} | |
| 736 | {descriptorLabel && descriptorLabel !== record.label ? ( | |
| 737 | <p className="text-xs text-muted-foreground">Server label: {descriptorLabel}</p> | |
| 738 | ) : null} | |
| 739 | </div> | |
| 740 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | |
| 741 | <Button | |
| 742 | size="xs" | |
| 743 | variant="outline" | |
| 744 | disabled={reconnectingEnvironmentId === environmentId} | |
| 745 | onClick={() => void onReconnect(environmentId)} | |
| 746 | > | |
| 747 | {reconnectingEnvironmentId === environmentId ? "Reconnecting…" : "Reconnect"} | |
| 748 | </Button> | |
| 749 | <Button | |
| 750 | size="xs" | |
| 751 | variant="destructive-outline" | |
| 752 | disabled={removingEnvironmentId === environmentId} | |
| 753 | onClick={() => void onRemove(environmentId)} | |
| 754 | > | |
| 755 | {removingEnvironmentId === environmentId ? "Removing…" : "Remove"} | |
| 756 | </Button> | |
| 757 | </div> | |
| 758 | </div> | |
| 759 | </div> | |
| 760 | ); | |
| 761 | } | |
| 762 | ||
| 763 | export function ConnectionsSettings() { | |
| 764 | const desktopBridge = window.desktopBridge; | |
| 765 | const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>( | |
| 766 | desktopBridge ? "owner" : null, | |
| 767 | ); | |
| 768 | const [currentAuthPolicy, setCurrentAuthPolicy] = useState< | |
| 769 | "desktop-managed-local" | "loopback-browser" | "remote-reachable" | "unsafe-no-auth" | null | |
| 770 | >(desktopBridge ? null : null); | |
| 771 | const savedEnvironmentsById = useSavedEnvironmentRegistryStore((state) => state.byId); | |
| 772 | const savedEnvironmentIds = useMemo( | |
| 773 | () => | |
| 774 | Object.values(savedEnvironmentsById) | |
| 775 | .toSorted((left, right) => left.label.localeCompare(right.label)) | |
| 776 | .map((record) => record.environmentId), | |
| 777 | [savedEnvironmentsById], | |
| 778 | ); | |
| 779 | ||
| 780 | const [desktopServerExposureState, setDesktopServerExposureState] = | |
| 781 | useState<DesktopServerExposureState | null>(null); | |
| 782 | const [desktopServerExposureError, setDesktopServerExposureError] = useState<string | null>(null); | |
| 783 | const [desktopPairingLinks, setDesktopPairingLinks] = useState< | |
| 784 | ReadonlyArray<ServerPairingLinkRecord> | |
| 785 | >([]); | |
| 786 | const [desktopClientSessions, setDesktopClientSessions] = useState< | |
| 787 | ReadonlyArray<ServerClientSessionRecord> | |
| 788 | >([]); | |
| 789 | const [desktopAccessManagementError, setDesktopAccessManagementError] = useState<string | null>( | |
| 790 | null, | |
| 791 | ); | |
| 792 | const [isLoadingDesktopAccessManagement, setIsLoadingDesktopAccessManagement] = useState(false); | |
| 793 | const [revokingDesktopPairingLinkId, setRevokingDesktopPairingLinkId] = useState<string | null>( | |
| 794 | null, | |
| 795 | ); | |
| 796 | const [revokingDesktopClientSessionId, setRevokingDesktopClientSessionId] = useState< | |
| 797 | string | null | |
| 798 | >(null); | |
| 799 | const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); | |
| 800 | const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); | |
| 801 | const [savedBackendMode, setSavedBackendMode] = useState<"pairing-url" | "host-code">( | |
| 802 | "pairing-url", | |
| 803 | ); | |
| 804 | const [savedBackendLabel, setSavedBackendLabel] = useState(""); | |
| 805 | const [savedBackendPairingUrl, setSavedBackendPairingUrl] = useState(""); | |
| 806 | const [savedBackendHost, setSavedBackendHost] = useState(""); | |
| 807 | const [savedBackendPairingCode, setSavedBackendPairingCode] = useState(""); | |
| 808 | const [savedBackendError, setSavedBackendError] = useState<string | null>(null); | |
| 809 | const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); | |
| 810 | const [reconnectingSavedEnvironmentId, setReconnectingSavedEnvironmentId] = | |
| 811 | useState<EnvironmentId | null>(null); | |
| 812 | const [removingSavedEnvironmentId, setRemovingSavedEnvironmentId] = | |
| 813 | useState<EnvironmentId | null>(null); | |
| 814 | const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); | |
| 815 | const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState< | |
| 816 | DesktopServerExposureState["mode"] | null | |
| 817 | >(null); | |
| 818 | const canManageLocalBackend = currentSessionRole === "owner"; | |
| 819 | const isLocalBackendNetworkAccessible = desktopBridge | |
| 820 | ? desktopServerExposureState?.mode === "network-accessible" | |
| 821 | : currentAuthPolicy === "remote-reachable"; | |
| 822 | ||
| 823 | const handleDesktopServerExposureChange = useCallback( | |
| 824 | async (checked: boolean) => { | |
| 825 | if (!desktopBridge) return; | |
| 826 | setIsUpdatingDesktopServerExposure(true); | |
| 827 | setDesktopServerExposureError(null); | |
| 828 | try { | |
| 829 | const nextState = await desktopBridge.setServerExposureMode( | |
| 830 | checked ? "network-accessible" : "local-only", | |
| 831 | ); | |
| 832 | setDesktopServerExposureState(nextState); | |
| 833 | setPendingDesktopServerExposureMode(null); | |
| 834 | setIsUpdatingDesktopServerExposure(false); | |
| 835 | } catch (error) { | |
| 836 | const message = | |
| 837 | error instanceof Error ? error.message : "Failed to update network exposure."; | |
| 838 | setPendingDesktopServerExposureMode(null); | |
| 839 | setDesktopServerExposureError(message); | |
| 840 | toastManager.add({ | |
| 841 | type: "error", | |
| 842 | title: "Could not update network access", | |
| 843 | description: message, | |
| 844 | }); | |
| 845 | setIsUpdatingDesktopServerExposure(false); | |
| 846 | } | |
| 847 | }, | |
| 848 | [desktopBridge], | |
| 849 | ); | |
| 850 | ||
| 851 | const handleConfirmDesktopServerExposureChange = useCallback(() => { | |
| 852 | if (pendingDesktopServerExposureMode === null) return; | |
| 853 | const checked = pendingDesktopServerExposureMode === "network-accessible"; | |
| 854 | void handleDesktopServerExposureChange(checked); | |
| 855 | }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]); | |
| 856 | ||
| 857 | const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { | |
| 858 | setRevokingDesktopPairingLinkId(id); | |
| 859 | setDesktopAccessManagementError(null); | |
| 860 | try { | |
| 861 | await revokeServerPairingLink(id); | |
| 862 | } catch (error) { | |
| 863 | const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; | |
| 864 | setDesktopAccessManagementError(message); | |
| 865 | toastManager.add({ | |
| 866 | type: "error", | |
| 867 | title: "Could not revoke pairing link", | |
| 868 | description: message, | |
| 869 | }); | |
| 870 | } finally { | |
| 871 | setRevokingDesktopPairingLinkId(null); | |
| 872 | } | |
| 873 | }, []); | |
| 874 | ||
| 875 | const handleRevokeDesktopClientSession = useCallback( | |
| 876 | async (sessionId: ServerClientSessionRecord["sessionId"]) => { | |
| 877 | setRevokingDesktopClientSessionId(sessionId); | |
| 878 | setDesktopAccessManagementError(null); | |
| 879 | try { | |
| 880 | await revokeServerClientSession(sessionId); | |
| 881 | } catch (error) { | |
| 882 | const message = error instanceof Error ? error.message : "Failed to revoke client access."; | |
| 883 | setDesktopAccessManagementError(message); | |
| 884 | toastManager.add({ | |
| 885 | type: "error", | |
| 886 | title: "Could not revoke client access", | |
| 887 | description: message, | |
| 888 | }); | |
| 889 | } finally { | |
| 890 | setRevokingDesktopClientSessionId(null); | |
| 891 | } | |
| 892 | }, | |
| 893 | [], | |
| 894 | ); | |
| 895 | ||
| 896 | const handleRevokeOtherDesktopClients = useCallback(async () => { | |
| 897 | setIsRevokingOtherDesktopClients(true); | |
| 898 | setDesktopAccessManagementError(null); | |
| 899 | try { | |
| 900 | const revokedCount = await revokeOtherServerClientSessions(); | |
| 901 | toastManager.add({ | |
| 902 | type: "success", | |
| 903 | title: revokedCount === 1 ? "Revoked 1 other client" : `Revoked ${revokedCount} clients`, | |
| 904 | description: "Other paired clients will need a new pairing link before reconnecting.", | |
| 905 | }); | |
| 906 | } catch (error) { | |
| 907 | const message = error instanceof Error ? error.message : "Failed to revoke other clients."; | |
| 908 | setDesktopAccessManagementError(message); | |
| 909 | toastManager.add({ | |
| 910 | type: "error", | |
| 911 | title: "Could not revoke other clients", | |
| 912 | description: message, | |
| 913 | }); | |
| 914 | } finally { | |
| 915 | setIsRevokingOtherDesktopClients(false); | |
| 916 | } | |
| 917 | }, []); | |
| 918 | ||
| 919 | const handleAddSavedBackend = useCallback(async () => { | |
| 920 | setIsAddingSavedBackend(true); | |
| 921 | setSavedBackendError(null); | |
| 922 | try { | |
| 923 | const record = await addSavedEnvironment({ | |
| 924 | label: savedBackendLabel, | |
| 925 | ...(savedBackendMode === "pairing-url" | |
| 926 | ? { pairingUrl: savedBackendPairingUrl } | |
| 927 | : { | |
| 928 | host: savedBackendHost, | |
| 929 | pairingCode: savedBackendPairingCode, | |
| 930 | }), | |
| 931 | }); | |
| 932 | setSavedBackendLabel(""); | |
| 933 | setSavedBackendPairingUrl(""); | |
| 934 | setSavedBackendHost(""); | |
| 935 | setSavedBackendPairingCode(""); | |
| 936 | setAddBackendDialogOpen(false); | |
| 937 | toastManager.add({ | |
| 938 | type: "success", | |
| 939 | title: "Backend added", | |
| 940 | description: `${record.label} is now saved and will reconnect on app startup.`, | |
| 941 | }); | |
| 942 | } catch (error) { | |
| 943 | const message = error instanceof Error ? error.message : "Failed to add backend."; | |
| 944 | setSavedBackendError(message); | |
| 945 | toastManager.add({ | |
| 946 | type: "error", | |
| 947 | title: "Could not add backend", | |
| 948 | description: message, | |
| 949 | }); | |
| 950 | } finally { | |
| 951 | setIsAddingSavedBackend(false); | |
| 952 | } | |
| 953 | }, [ | |
| 954 | savedBackendHost, | |
| 955 | savedBackendLabel, | |
| 956 | savedBackendMode, | |
| 957 | savedBackendPairingCode, | |
| 958 | savedBackendPairingUrl, | |
| 959 | ]); | |
| 960 | ||
| 961 | const handleReconnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => { | |
| 962 | setReconnectingSavedEnvironmentId(environmentId); | |
| 963 | setSavedBackendError(null); | |
| 964 | try { | |
| 965 | await reconnectSavedEnvironment(environmentId); | |
| 966 | } catch (error) { | |
| 967 | const message = error instanceof Error ? error.message : "Failed to reconnect backend."; | |
| 968 | setSavedBackendError(message); | |
| 969 | toastManager.add({ | |
| 970 | type: "error", | |
| 971 | title: "Could not reconnect backend", | |
| 972 | description: message, | |
| 973 | }); | |
| 974 | } finally { | |
| 975 | setReconnectingSavedEnvironmentId(null); | |
| 976 | } | |
| 977 | }, []); | |
| 978 | ||
| 979 | const handleRemoveSavedBackend = useCallback(async (environmentId: EnvironmentId) => { | |
| 980 | setRemovingSavedEnvironmentId(environmentId); | |
| 981 | setSavedBackendError(null); | |
| 982 | try { | |
| 983 | await removeSavedEnvironment(environmentId); | |
| 984 | } catch (error) { | |
| 985 | const message = error instanceof Error ? error.message : "Failed to remove backend."; | |
| 986 | setSavedBackendError(message); | |
| 987 | toastManager.add({ | |
| 988 | type: "error", | |
| 989 | title: "Could not remove backend", | |
| 990 | description: message, | |
| 991 | }); | |
| 992 | } finally { | |
| 993 | setRemovingSavedEnvironmentId(null); | |
| 994 | } | |
| 995 | }, []); | |
| 996 | ||
| 997 | useEffect(() => { | |
| 998 | if (desktopBridge) { | |
| 999 | setCurrentSessionRole("owner"); | |
| 1000 | return; | |
| 1001 | } | |
| 1002 | ||
| 1003 | let cancelled = false; | |
| 1004 | void fetchSessionState() | |
| 1005 | .then((session) => { | |
| 1006 | if (cancelled) return; | |
| 1007 | setCurrentSessionRole(session.authenticated ? (session.role ?? null) : null); | |
| 1008 | setCurrentAuthPolicy(session.auth.policy); | |
| 1009 | }) | |
| 1010 | .catch(() => { | |
| 1011 | if (cancelled) return; | |
| 1012 | setCurrentSessionRole(null); | |
| 1013 | setCurrentAuthPolicy(null); | |
| 1014 | }); | |
| 1015 | ||
| 1016 | return () => { | |
| 1017 | cancelled = true; | |
| 1018 | }; | |
| 1019 | }, [desktopBridge]); | |
| 1020 | ||
| 1021 | useEffect(() => { | |
| 1022 | if (!canManageLocalBackend) return; | |
| 1023 | ||
| 1024 | let cancelled = false; | |
| 1025 | setIsLoadingDesktopAccessManagement(true); | |
| 1026 | type AuthAccessEvent = Parameters< | |
| 1027 | Parameters<WsRpcClient["server"]["subscribeAuthAccess"]>[0] | |
| 1028 | >[0]; | |
| 1029 | const unsubscribeAuthAccess = | |
| 1030 | getPrimaryEnvironmentConnection().client.server.subscribeAuthAccess( | |
| 1031 | (event: AuthAccessEvent) => { | |
| 1032 | if (cancelled) { | |
| 1033 | return; | |
| 1034 | } | |
| 1035 | ||
| 1036 | switch (event.type) { | |
| 1037 | case "snapshot": | |
| 1038 | setDesktopPairingLinks( | |
| 1039 | sortDesktopPairingLinks( | |
| 1040 | event.payload.pairingLinks.map((pairingLink: AuthPairingLink) => | |
| 1041 | toDesktopPairingLinkRecord(pairingLink), | |
| 1042 | ), | |
| 1043 | ), | |
| 1044 | ); | |
| 1045 | setDesktopClientSessions( | |
| 1046 | sortDesktopClientSessions( | |
| 1047 | event.payload.clientSessions.map((clientSession: AuthClientSession) => | |
| 1048 | toDesktopClientSessionRecord(clientSession), | |
| 1049 | ), | |
| 1050 | ), | |
| 1051 | ); | |
| 1052 | break; | |
| 1053 | case "pairingLinkUpserted": | |
| 1054 | setDesktopPairingLinks((current) => | |
| 1055 | upsertDesktopPairingLink(current, toDesktopPairingLinkRecord(event.payload)), | |
| 1056 | ); | |
| 1057 | break; | |
| 1058 | case "pairingLinkRemoved": | |
| 1059 | setDesktopPairingLinks((current) => | |
| 1060 | removeDesktopPairingLink(current, event.payload.id), | |
| 1061 | ); | |
| 1062 | break; | |
| 1063 | case "clientUpserted": | |
| 1064 | setDesktopClientSessions((current) => | |
| 1065 | upsertDesktopClientSession(current, toDesktopClientSessionRecord(event.payload)), | |
| 1066 | ); | |
| 1067 | break; | |
| 1068 | case "clientRemoved": | |
| 1069 | setDesktopClientSessions((current) => | |
| 1070 | removeDesktopClientSession(current, event.payload.sessionId), | |
| 1071 | ); | |
| 1072 | break; | |
| 1073 | } | |
| 1074 | ||
| 1075 | setDesktopAccessManagementError(null); | |
| 1076 | setIsLoadingDesktopAccessManagement(false); | |
| 1077 | }, | |
| 1078 | { | |
| 1079 | onResubscribe: () => { | |
| 1080 | if (!cancelled) { | |
| 1081 | setIsLoadingDesktopAccessManagement(true); | |
| 1082 | } | |
| 1083 | }, | |
| 1084 | }, | |
| 1085 | ); | |
| 1086 | if (desktopBridge) { | |
| 1087 | void desktopBridge | |
| 1088 | .getServerExposureState() | |
| 1089 | .then((state) => { | |
| 1090 | if (cancelled) return; | |
| 1091 | setDesktopServerExposureState(state); | |
| 1092 | }) | |
| 1093 | .catch((error: unknown) => { | |
| 1094 | if (cancelled) return; | |
| 1095 | const message = | |
| 1096 | error instanceof Error ? error.message : "Failed to load network exposure state."; | |
| 1097 | setDesktopServerExposureError(message); | |
| 1098 | }); | |
| 1099 | } else { | |
| 1100 | setDesktopServerExposureState(null); | |
| 1101 | setDesktopServerExposureError(null); | |
| 1102 | } | |
| 1103 | ||
| 1104 | return () => { | |
| 1105 | cancelled = true; | |
| 1106 | unsubscribeAuthAccess(); | |
| 1107 | }; | |
| 1108 | }, [canManageLocalBackend, desktopBridge]); | |
| 1109 | ||
| 1110 | useEffect(() => { | |
| 1111 | if (canManageLocalBackend) return; | |
| 1112 | setIsLoadingDesktopAccessManagement(false); | |
| 1113 | setDesktopPairingLinks([]); | |
| 1114 | setDesktopClientSessions([]); | |
| 1115 | setDesktopAccessManagementError(null); | |
| 1116 | setDesktopServerExposureState(null); | |
| 1117 | setDesktopServerExposureError(null); | |
| 1118 | }, [canManageLocalBackend]); | |
| 1119 | const visibleDesktopPairingLinks = useMemo( | |
| 1120 | () => desktopPairingLinks.filter((pairingLink) => pairingLink.role === "client"), | |
| 1121 | [desktopPairingLinks], | |
| 1122 | ); | |
| 1123 | return ( | |
| 1124 | <SettingsPageContainer> | |
| 1125 | {canManageLocalBackend ? ( | |
| 1126 | <> | |
| 1127 | <SettingsSection title="Manage local backend"> | |
| 1128 | {desktopBridge ? ( | |
| 1129 | <SettingsRow | |
| 1130 | title="Network access" | |
| 1131 | description={ | |
| 1132 | desktopServerExposureState?.endpointUrl | |
| 1133 | ? `Reachable at ${desktopServerExposureState.endpointUrl}` | |
| 1134 | : desktopServerExposureState?.mode === "network-accessible" | |
| 1135 | ? desktopServerExposureState.advertisedHost | |
| 1136 | ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.` | |
| 1137 | : "Exposed on all interfaces." | |
| 1138 | : desktopServerExposureState | |
| 1139 | ? "Limited to this machine." | |
| 1140 | : "Loading…" | |
| 1141 | } | |
| 1142 | status={ | |
| 1143 | desktopServerExposureError ? ( | |
| 1144 | <span className="block text-destructive">{desktopServerExposureError}</span> | |
| 1145 | ) : null | |
| 1146 | } | |
| 1147 | control={ | |
| 1148 | <AlertDialog | |
| 1149 | open={pendingDesktopServerExposureMode !== null} | |
| 1150 | onOpenChange={(open) => { | |
| 1151 | if (isUpdatingDesktopServerExposure) return; | |
| 1152 | if (!open) setPendingDesktopServerExposureMode(null); | |
| 1153 | }} | |
| 1154 | > | |
| 1155 | <Switch | |
| 1156 | checked={desktopServerExposureState?.mode === "network-accessible"} | |
| 1157 | disabled={!desktopServerExposureState || isUpdatingDesktopServerExposure} | |
| 1158 | onCheckedChange={(checked) => { | |
| 1159 | setPendingDesktopServerExposureMode( | |
| 1160 | checked ? "network-accessible" : "local-only", | |
| 1161 | ); | |
| 1162 | }} | |
| 1163 | aria-label="Enable network access" | |
| 1164 | /> | |
| 1165 | <AlertDialogPopup> | |
| 1166 | <AlertDialogHeader> | |
| 1167 | <AlertDialogTitle> | |
| 1168 | {pendingDesktopServerExposureMode === "network-accessible" | |
| 1169 | ? "Enable network access?" | |
| 1170 | : "Disable network access?"} | |
| 1171 | </AlertDialogTitle> | |
| 1172 | <AlertDialogDescription> | |
| 1173 | {pendingDesktopServerExposureMode === "network-accessible" | |
| 1174 | ? "T3 Code will restart to expose this environment over the network." | |
| 1175 | : "T3 Code will restart and limit this environment back to this machine."} | |
| 1176 | </AlertDialogDescription> | |
| 1177 | </AlertDialogHeader> | |
| 1178 | <AlertDialogFooter> | |
| 1179 | <AlertDialogClose | |
| 1180 | disabled={isUpdatingDesktopServerExposure} | |
| 1181 | render={ | |
| 1182 | <Button variant="outline" disabled={isUpdatingDesktopServerExposure} /> | |
| 1183 | } | |
| 1184 | > | |
| 1185 | Cancel | |
| 1186 | </AlertDialogClose> | |
| 1187 | <Button | |
| 1188 | onClick={handleConfirmDesktopServerExposureChange} | |
| 1189 | disabled={ | |
| 1190 | pendingDesktopServerExposureMode === null || | |
| 1191 | isUpdatingDesktopServerExposure | |
| 1192 | } | |
| 1193 | > | |
| 1194 | {isUpdatingDesktopServerExposure ? ( | |
| 1195 | <> | |
| 1196 | <Spinner className="size-3.5" /> | |
| 1197 | Restarting… | |
| 1198 | </> | |
| 1199 | ) : pendingDesktopServerExposureMode === "network-accessible" ? ( | |
| 1200 | "Restart and enable" | |
| 1201 | ) : ( | |
| 1202 | "Restart and disable" | |
| 1203 | )} | |
| 1204 | </Button> | |
| 1205 | </AlertDialogFooter> | |
| 1206 | </AlertDialogPopup> | |
| 1207 | </AlertDialog> | |
| 1208 | } | |
| 1209 | /> | |
| 1210 | ) : ( | |
| 1211 | <SettingsRow | |
| 1212 | title="Network access" | |
| 1213 | description={ | |
| 1214 | currentAuthPolicy === "remote-reachable" | |
| 1215 | ? "This backend is already configured for remote access. Network exposure changes must be made where the server is launched." | |
| 1216 | : "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing." | |
| 1217 | } | |
| 1218 | control={ | |
| 1219 | <Tooltip> | |
| 1220 | <TooltipTrigger | |
| 1221 | render={ | |
| 1222 | <span className="inline-flex"> | |
| 1223 | <Switch | |
| 1224 | checked={isLocalBackendNetworkAccessible} | |
| 1225 | disabled | |
| 1226 | aria-label="Enable network access" | |
| 1227 | /> | |
| 1228 | </span> | |
| 1229 | } | |
| 1230 | /> | |
| 1231 | <TooltipPopup side="top"> | |
| 1232 | Network exposure changes restart the backend and must be controlled where the | |
| 1233 | server process is launched. | |
| 1234 | </TooltipPopup> | |
| 1235 | </Tooltip> | |
| 1236 | } | |
| 1237 | /> | |
| 1238 | )} | |
| 1239 | </SettingsSection> | |
| 1240 | ||
| 1241 | {isLocalBackendNetworkAccessible ? ( | |
| 1242 | <SettingsSection | |
| 1243 | title="Authorized clients" | |
| 1244 | headerAction={ | |
| 1245 | <AuthorizedClientsHeaderAction | |
| 1246 | clientSessions={desktopClientSessions} | |
| 1247 | isRevokingOtherClients={isRevokingOtherDesktopClients} | |
| 1248 | onRevokeOtherClients={handleRevokeOtherDesktopClients} | |
| 1249 | /> | |
| 1250 | } | |
| 1251 | > | |
| 1252 | {desktopAccessManagementError ? ( | |
| 1253 | <div className={ITEM_ROW_CLASSNAME}> | |
| 1254 | <p className="text-xs text-destructive">{desktopAccessManagementError}</p> | |
| 1255 | </div> | |
| 1256 | ) : null} | |
| 1257 | <PairingClientsList | |
| 1258 | endpointUrl={desktopServerExposureState?.endpointUrl} | |
| 1259 | isLoading={isLoadingDesktopAccessManagement} | |
| 1260 | pairingLinks={visibleDesktopPairingLinks} | |
| 1261 | clientSessions={desktopClientSessions} | |
| 1262 | revokingPairingLinkId={revokingDesktopPairingLinkId} | |
| 1263 | revokingClientSessionId={revokingDesktopClientSessionId} | |
| 1264 | onRevokePairingLink={handleRevokeDesktopPairingLink} | |
| 1265 | onRevokeClientSession={handleRevokeDesktopClientSession} | |
| 1266 | /> | |
| 1267 | </SettingsSection> | |
| 1268 | ) : null} | |
| 1269 | </> | |
| 1270 | ) : ( | |
| 1271 | <SettingsSection title="Local backend access"> | |
| 1272 | <SettingsRow | |
| 1273 | title="Owner tools" | |
| 1274 | description="Pairing links and client-session management are only available to owner sessions for this backend." | |
| 1275 | /> | |
| 1276 | </SettingsSection> | |
| 1277 | )} | |
| 1278 | ||
| 1279 | <SettingsSection | |
| 1280 | title="Remote environments" | |
| 1281 | headerAction={ | |
| 1282 | <Dialog | |
| 1283 | open={addBackendDialogOpen} | |
| 1284 | onOpenChange={(open) => { | |
| 1285 | setAddBackendDialogOpen(open); | |
| 1286 | if (!open) { | |
| 1287 | setSavedBackendError(null); | |
| 1288 | } | |
| 1289 | }} | |
| 1290 | > | |
| 1291 | <DialogTrigger | |
| 1292 | render={ | |
| 1293 | <Button size="xs" variant="outline"> | |
| 1294 | <PlusIcon className="size-3" /> | |
| 1295 | Add environment | |
| 1296 | </Button> | |
| 1297 | } | |
| 1298 | /> | |
| 1299 | <DialogPopup> | |
| 1300 | <DialogHeader> | |
| 1301 | <DialogTitle>Add Environment</DialogTitle> | |
| 1302 | <DialogDescription>Pair another environment to this client.</DialogDescription> | |
| 1303 | <div className="flex gap-1 rounded-lg border border-border/60 bg-muted/50 p-1"> | |
| 1304 | <button | |
| 1305 | type="button" | |
| 1306 | className={cn( | |
| 1307 | "flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors", | |
| 1308 | savedBackendMode === "pairing-url" | |
| 1309 | ? "bg-background text-foreground shadow-xs" | |
| 1310 | : "text-muted-foreground hover:text-foreground", | |
| 1311 | )} | |
| 1312 | disabled={isAddingSavedBackend} | |
| 1313 | onClick={() => setSavedBackendMode("pairing-url")} | |
| 1314 | > | |
| 1315 | Pairing URL | |
| 1316 | </button> | |
| 1317 | <button | |
| 1318 | type="button" | |
| 1319 | className={cn( | |
| 1320 | "flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors", | |
| 1321 | savedBackendMode === "host-code" | |
| 1322 | ? "bg-background text-foreground shadow-xs" | |
| 1323 | : "text-muted-foreground hover:text-foreground", | |
| 1324 | )} | |
| 1325 | disabled={isAddingSavedBackend} | |
| 1326 | onClick={() => setSavedBackendMode("host-code")} | |
| 1327 | > | |
| 1328 | Host + code | |
| 1329 | </button> | |
| 1330 | </div> | |
| 1331 | </DialogHeader> | |
| 1332 | <DialogPanel> | |
| 1333 | <div className="space-y-4"> | |
| 1334 | {savedBackendMode === "pairing-url" ? ( | |
| 1335 | <p className="text-xs text-muted-foreground"> | |
| 1336 | Enter the full pairing URL from the environment you want to connect to. | |
| 1337 | </p> | |
| 1338 | ) : ( | |
| 1339 | <p className="text-xs text-muted-foreground"> | |
| 1340 | Enter the backend host and pairing code separately. | |
| 1341 | </p> | |
| 1342 | )} | |
| 1343 | <div className="space-y-3"> | |
| 1344 | <label className="block"> | |
| 1345 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | |
| 1346 | Label | |
| 1347 | </span> | |
| 1348 | <Input | |
| 1349 | value={savedBackendLabel} | |
| 1350 | onChange={(event) => setSavedBackendLabel(event.target.value)} | |
| 1351 | placeholder="My backend (optional)" | |
| 1352 | disabled={isAddingSavedBackend} | |
| 1353 | spellCheck={false} | |
| 1354 | /> | |
| 1355 | </label> | |
| 1356 | {savedBackendMode === "pairing-url" ? ( | |
| 1357 | <label className="block"> | |
| 1358 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | |
| 1359 | Pairing URL | |
| 1360 | </span> | |
| 1361 | <Input | |
| 1362 | value={savedBackendPairingUrl} | |
| 1363 | onChange={(event) => setSavedBackendPairingUrl(event.target.value)} | |
| 1364 | placeholder="https://backend.example.com/pair#token=..." | |
| 1365 | disabled={isAddingSavedBackend} | |
| 1366 | spellCheck={false} | |
| 1367 | /> | |
| 1368 | <span className="mt-1 block text-[11px] text-muted-foreground"> | |
| 1369 | The full URL including the pairing token. | |
| 1370 | </span> | |
| 1371 | </label> | |
| 1372 | ) : ( | |
| 1373 | <> | |
| 1374 | <label className="block"> | |
| 1375 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | |
| 1376 | Host | |
| 1377 | </span> | |
| 1378 | <Input | |
| 1379 | value={savedBackendHost} | |
| 1380 | onChange={(event) => setSavedBackendHost(event.target.value)} | |
| 1381 | placeholder="https://backend.example.com" | |
| 1382 | disabled={isAddingSavedBackend} | |
| 1383 | spellCheck={false} | |
| 1384 | /> | |
| 1385 | </label> | |
| 1386 | <label className="block"> | |
| 1387 | <span className="mb-1.5 block text-xs font-medium text-foreground"> | |
| 1388 | Pairing code | |
| 1389 | </span> | |
| 1390 | <Input | |
| 1391 | value={savedBackendPairingCode} | |
| 1392 | onChange={(event) => setSavedBackendPairingCode(event.target.value)} | |
| 1393 | placeholder="Pairing code" | |
| 1394 | disabled={isAddingSavedBackend} | |
| 1395 | spellCheck={false} | |
| 1396 | /> | |
| 1397 | </label> | |
| 1398 | </> | |
| 1399 | )} | |
| 1400 | </div> | |
| 1401 | {savedBackendError ? ( | |
| 1402 | <p className="text-xs text-destructive">{savedBackendError}</p> | |
| 1403 | ) : null} | |
| 1404 | <Button | |
| 1405 | variant="outline" | |
| 1406 | className="w-full" | |
| 1407 | disabled={isAddingSavedBackend} | |
| 1408 | onClick={() => void handleAddSavedBackend()} | |
| 1409 | > | |
| 1410 | <PlusIcon className="size-3.5" /> | |
| 1411 | {isAddingSavedBackend ? "Adding…" : "Add Backend"} | |
| 1412 | </Button> | |
| 1413 | </div> | |
| 1414 | </DialogPanel> | |
| 1415 | </DialogPopup> | |
| 1416 | </Dialog> | |
| 1417 | } | |
| 1418 | > | |
| 1419 | {savedEnvironmentIds.map((environmentId) => ( | |
| 1420 | <SavedBackendListRow | |
| 1421 | key={environmentId} | |
| 1422 | environmentId={environmentId} | |
| 1423 | reconnectingEnvironmentId={reconnectingSavedEnvironmentId} | |
| 1424 | removingEnvironmentId={removingSavedEnvironmentId} | |
| 1425 | onReconnect={handleReconnectSavedBackend} | |
| 1426 | onRemove={handleRemoveSavedBackend} | |
| 1427 | /> | |
| 1428 | ))} | |
| 1429 | ||
| 1430 | {savedEnvironmentIds.length === 0 ? ( | |
| 1431 | <div className={ITEM_ROW_CLASSNAME}> | |
| 1432 | <p className="text-xs text-muted-foreground"> | |
| 1433 | No remote environments yet. Click &ldquo;Add environment&rdquo; to pair another | |
| 1434 | environment. | |
| 1435 | </p> | |
| 1436 | </div> | |
| 1437 | ) : null} | |
| 1438 | </SettingsSection> | |
| 1439 | </SettingsPageContainer> | |
| 1440 | ); | |
| 1441 | } |
apps/web/src/components/settings/SettingsPanels.browser.tsx+588-4| ... | ... | @@ -1,11 +1,17 @@ |
| 1 | 1 | import "../../index.css"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | type AuthAccessStreamEvent, | |
| 5 | type AuthAccessSnapshot, | |
| 6 | AuthSessionId, | |
| 4 | 7 | DEFAULT_SERVER_SETTINGS, |
| 5 | 8 | EnvironmentId, |
| 9 | type DesktopBridge, | |
| 10 | type DesktopUpdateState, | |
| 6 | 11 | type LocalApi, |
| 7 | 12 | type ServerConfig, |
| 8 | 13 | } from "@t3tools/contracts"; |
| 14 | import { DateTime } from "effect"; | |
| 9 | 15 | import { page } from "vitest/browser"; |
| 10 | 16 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 11 | 17 | import { render } from "vitest-browser-react"; |
| ... | ... | @@ -13,8 +19,155 @@ import { render } from "vitest-browser-react"; |
| 13 | 19 | import { __resetLocalApiForTests } from "../../localApi"; |
| 14 | 20 | import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; |
| 15 | 21 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; |
| 22 | import { ConnectionsSettings } from "./ConnectionsSettings"; | |
| 16 | 23 | import { GeneralSettingsPanel } from "./SettingsPanels"; |
| 17 | 24 | |
| 25 | const authAccessHarness = vi.hoisted(() => { | |
| 26 | type Snapshot = AuthAccessSnapshot; | |
| 27 | let snapshot: Snapshot = { | |
| 28 | pairingLinks: [], | |
| 29 | clientSessions: [], | |
| 30 | }; | |
| 31 | let revision = 1; | |
| 32 | const listeners = new Set<(event: AuthAccessStreamEvent) => void>(); | |
| 33 | ||
| 34 | const emitEvent = (event: AuthAccessStreamEvent) => { | |
| 35 | for (const listener of listeners) { | |
| 36 | listener(event); | |
| 37 | } | |
| 38 | }; | |
| 39 | ||
| 40 | return { | |
| 41 | reset() { | |
| 42 | snapshot = { | |
| 43 | pairingLinks: [], | |
| 44 | clientSessions: [], | |
| 45 | }; | |
| 46 | revision = 1; | |
| 47 | listeners.clear(); | |
| 48 | }, | |
| 49 | setSnapshot(next: Snapshot) { | |
| 50 | snapshot = next; | |
| 51 | }, | |
| 52 | emitSnapshot() { | |
| 53 | emitEvent({ | |
| 54 | version: 1 as const, | |
| 55 | revision, | |
| 56 | type: "snapshot" as const, | |
| 57 | payload: snapshot, | |
| 58 | }); | |
| 59 | revision += 1; | |
| 60 | }, | |
| 61 | emitEvent, | |
| 62 | emitPairingLinkUpserted(pairingLink: Snapshot["pairingLinks"][number]) { | |
| 63 | emitEvent({ | |
| 64 | version: 1, | |
| 65 | revision, | |
| 66 | type: "pairingLinkUpserted", | |
| 67 | payload: pairingLink, | |
| 68 | }); | |
| 69 | revision += 1; | |
| 70 | }, | |
| 71 | emitPairingLinkRemoved(id: string) { | |
| 72 | emitEvent({ | |
| 73 | version: 1, | |
| 74 | revision, | |
| 75 | type: "pairingLinkRemoved", | |
| 76 | payload: { id }, | |
| 77 | }); | |
| 78 | revision += 1; | |
| 79 | }, | |
| 80 | emitClientUpserted(clientSession: Snapshot["clientSessions"][number]) { | |
| 81 | emitEvent({ | |
| 82 | version: 1, | |
| 83 | revision, | |
| 84 | type: "clientUpserted", | |
| 85 | payload: clientSession, | |
| 86 | }); | |
| 87 | revision += 1; | |
| 88 | }, | |
| 89 | emitClientRemoved(sessionId: string) { | |
| 90 | emitEvent({ | |
| 91 | version: 1, | |
| 92 | revision, | |
| 93 | type: "clientRemoved", | |
| 94 | payload: { | |
| 95 | sessionId: AuthSessionId.makeUnsafe(sessionId), | |
| 96 | }, | |
| 97 | }); | |
| 98 | revision += 1; | |
| 99 | }, | |
| 100 | subscribe(listener: (event: AuthAccessStreamEvent) => void) { | |
| 101 | listeners.add(listener); | |
| 102 | listener({ | |
| 103 | version: 1, | |
| 104 | revision: 1, | |
| 105 | type: "snapshot", | |
| 106 | payload: snapshot, | |
| 107 | }); | |
| 108 | return () => { | |
| 109 | listeners.delete(listener); | |
| 110 | }; | |
| 111 | }, | |
| 112 | }; | |
| 113 | }); | |
| 114 | ||
| 115 | vi.mock("../../environments/runtime", () => { | |
| 116 | const primaryConnection = { | |
| 117 | kind: "primary" as const, | |
| 118 | knownEnvironment: { | |
| 119 | id: "environment-local", | |
| 120 | label: "Local environment", | |
| 121 | source: "manual" as const, | |
| 122 | environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 123 | target: { | |
| 124 | httpBaseUrl: "http://localhost:3000", | |
| 125 | wsBaseUrl: "ws://localhost:3000", | |
| 126 | }, | |
| 127 | }, | |
| 128 | environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 129 | client: { | |
| 130 | server: { | |
| 131 | subscribeAuthAccess: (listener: Parameters<typeof authAccessHarness.subscribe>[0]) => | |
| 132 | authAccessHarness.subscribe(listener), | |
| 133 | }, | |
| 134 | }, | |
| 135 | ensureBootstrapped: async () => undefined, | |
| 136 | reconnect: async () => undefined, | |
| 137 | dispose: async () => undefined, | |
| 138 | }; | |
| 139 | ||
| 140 | return { | |
| 141 | getEnvironmentHttpBaseUrl: () => "http://localhost:3000", | |
| 142 | getSavedEnvironmentRecord: () => null, | |
| 143 | getSavedEnvironmentRuntimeState: () => null, | |
| 144 | hasSavedEnvironmentRegistryHydrated: () => true, | |
| 145 | listSavedEnvironmentRecords: () => [], | |
| 146 | resetSavedEnvironmentRegistryStoreForTests: () => undefined, | |
| 147 | resetSavedEnvironmentRuntimeStoreForTests: () => undefined, | |
| 148 | resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => | |
| 149 | new URL(path, "http://localhost:3000").toString(), | |
| 150 | waitForSavedEnvironmentRegistryHydration: async () => undefined, | |
| 151 | addSavedEnvironment: vi.fn(), | |
| 152 | disconnectSavedEnvironment: vi.fn(), | |
| 153 | ensureEnvironmentConnectionBootstrapped: async () => undefined, | |
| 154 | getPrimaryEnvironmentConnection: () => primaryConnection, | |
| 155 | readEnvironmentConnection: () => primaryConnection, | |
| 156 | reconnectSavedEnvironment: vi.fn(), | |
| 157 | removeSavedEnvironment: vi.fn(), | |
| 158 | requireEnvironmentConnection: () => primaryConnection, | |
| 159 | resetEnvironmentServiceForTests: () => undefined, | |
| 160 | startEnvironmentConnectionService: () => undefined, | |
| 161 | subscribeEnvironmentConnections: () => () => {}, | |
| 162 | useSavedEnvironmentRegistryStore: ( | |
| 163 | selector: (state: { byId: Record<string, never> }) => unknown, | |
| 164 | ) => selector({ byId: {} }), | |
| 165 | useSavedEnvironmentRuntimeStore: ( | |
| 166 | selector: (state: { byId: Record<string, never> }) => unknown, | |
| 167 | ) => selector({ byId: {} }), | |
| 168 | }; | |
| 169 | }); | |
| 170 | ||
| 18 | 171 | function createBaseServerConfig(): ServerConfig { |
| 19 | 172 | return { |
| 20 | 173 | environment: { |
| ... | ... | @@ -24,6 +177,12 @@ function createBaseServerConfig(): ServerConfig { |
| 24 | 177 | serverVersion: "0.0.0-test", |
| 25 | 178 | capabilities: { repositoryIdentity: true }, |
| 26 | 179 | }, |
| 180 | auth: { | |
| 181 | policy: "loopback-browser", | |
| 182 | bootstrapMethods: ["one-time-token"], | |
| 183 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 184 | sessionCookieName: "t3_session", | |
| 185 | }, | |
| 27 | 186 | cwd: "/repo/project", |
| 28 | 187 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 29 | 188 | keybindings: [], |
| ... | ... | @@ -41,24 +200,222 @@ function createBaseServerConfig(): ServerConfig { |
| 41 | 200 | }; |
| 42 | 201 | } |
| 43 | 202 | |
| 203 | function makeUtc(value: string) { | |
| 204 | return DateTime.makeUnsafe(Date.parse(value)); | |
| 205 | } | |
| 206 | ||
| 207 | function makePairingLink(input: { | |
| 208 | readonly id: string; | |
| 209 | readonly credential: string; | |
| 210 | readonly role: "owner" | "client"; | |
| 211 | readonly subject: string; | |
| 212 | readonly label?: string; | |
| 213 | readonly createdAt: string; | |
| 214 | readonly expiresAt: string; | |
| 215 | }): AuthAccessSnapshot["pairingLinks"][number] { | |
| 216 | return { | |
| 217 | ...input, | |
| 218 | createdAt: makeUtc(input.createdAt), | |
| 219 | expiresAt: makeUtc(input.expiresAt), | |
| 220 | }; | |
| 221 | } | |
| 222 | ||
| 223 | function makeClientSession(input: { | |
| 224 | readonly sessionId: string; | |
| 225 | readonly subject: string; | |
| 226 | readonly role: "owner" | "client"; | |
| 227 | readonly method: "browser-session-cookie"; | |
| 228 | readonly client?: { | |
| 229 | readonly label?: string; | |
| 230 | readonly ipAddress?: string; | |
| 231 | readonly userAgent?: string; | |
| 232 | readonly deviceType?: "desktop" | "mobile" | "tablet" | "bot" | "unknown"; | |
| 233 | readonly os?: string; | |
| 234 | readonly browser?: string; | |
| 235 | }; | |
| 236 | readonly issuedAt: string; | |
| 237 | readonly expiresAt: string; | |
| 238 | readonly lastConnectedAt?: string | null; | |
| 239 | readonly connected: boolean; | |
| 240 | readonly current: boolean; | |
| 241 | }): AuthAccessSnapshot["clientSessions"][number] { | |
| 242 | return { | |
| 243 | ...input, | |
| 244 | client: { | |
| 245 | deviceType: "unknown", | |
| 246 | ...input.client, | |
| 247 | }, | |
| 248 | sessionId: AuthSessionId.makeUnsafe(input.sessionId), | |
| 249 | issuedAt: makeUtc(input.issuedAt), | |
| 250 | expiresAt: makeUtc(input.expiresAt), | |
| 251 | lastConnectedAt: | |
| 252 | input.lastConnectedAt === undefined || input.lastConnectedAt === null | |
| 253 | ? null | |
| 254 | : makeUtc(input.lastConnectedAt), | |
| 255 | }; | |
| 256 | } | |
| 257 | ||
| 258 | const createDesktopBridgeStub = (overrides?: { | |
| 259 | readonly serverExposureState?: Awaited<ReturnType<DesktopBridge["getServerExposureState"]>>; | |
| 260 | readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; | |
| 261 | }): DesktopBridge => { | |
| 262 | const idleUpdateState: DesktopUpdateState = { | |
| 263 | enabled: false, | |
| 264 | status: "idle", | |
| 265 | currentVersion: "0.0.0-test", | |
| 266 | hostArch: "arm64", | |
| 267 | appArch: "arm64", | |
| 268 | runningUnderArm64Translation: false, | |
| 269 | availableVersion: null, | |
| 270 | downloadedVersion: null, | |
| 271 | downloadPercent: null, | |
| 272 | checkedAt: null, | |
| 273 | message: null, | |
| 274 | errorContext: null, | |
| 275 | canRetry: false, | |
| 276 | }; | |
| 277 | ||
| 278 | return { | |
| 279 | getLocalEnvironmentBootstrap: () => ({ | |
| 280 | label: "Local environment", | |
| 281 | httpBaseUrl: "http://127.0.0.1:3773", | |
| 282 | wsBaseUrl: "ws://127.0.0.1:3773", | |
| 283 | bootstrapToken: "desktop-bootstrap-token", | |
| 284 | }), | |
| 285 | getServerExposureState: vi.fn().mockResolvedValue( | |
| 286 | overrides?.serverExposureState ?? { | |
| 287 | mode: "local-only", | |
| 288 | endpointUrl: null, | |
| 289 | advertisedHost: null, | |
| 290 | }, | |
| 291 | ), | |
| 292 | setServerExposureMode: | |
| 293 | overrides?.setServerExposureMode ?? | |
| 294 | vi.fn().mockImplementation(async (mode) => ({ | |
| 295 | mode, | |
| 296 | endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null, | |
| 297 | advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null, | |
| 298 | })), | |
| 299 | pickFolder: vi.fn().mockResolvedValue(null), | |
| 300 | confirm: vi.fn().mockResolvedValue(false), | |
| 301 | setTheme: vi.fn().mockResolvedValue(undefined), | |
| 302 | showContextMenu: vi.fn().mockResolvedValue(null), | |
| 303 | openExternal: vi.fn().mockResolvedValue(true), | |
| 304 | onMenuAction: () => () => {}, | |
| 305 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), | |
| 306 | checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }), | |
| 307 | downloadUpdate: vi | |
| 308 | .fn() | |
| 309 | .mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }), | |
| 310 | installUpdate: vi | |
| 311 | .fn() | |
| 312 | .mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }), | |
| 313 | onUpdateState: () => () => {}, | |
| 314 | }; | |
| 315 | }; | |
| 316 | ||
| 44 | 317 | describe("GeneralSettingsPanel observability", () => { |
| 318 | let mounted: | |
| 319 | | (Awaited<ReturnType<typeof render>> & { | |
| 320 | cleanup?: () => Promise<void>; | |
| 321 | unmount?: () => Promise<void>; | |
| 322 | }) | |
| 323 | | null = null; | |
| 324 | ||
| 45 | 325 | beforeEach(async () => { |
| 46 | 326 | resetServerStateForTests(); |
| 47 | 327 | await __resetLocalApiForTests(); |
| 48 | 328 | localStorage.clear(); |
| 49 | document.body.innerHTML = ""; | |
| 329 | authAccessHarness.reset(); | |
| 50 | 330 | }); |
| 51 | 331 | |
| 52 | 332 | afterEach(async () => { |
| 333 | if (mounted) { | |
| 334 | const teardown = mounted.cleanup ?? mounted.unmount; | |
| 335 | await teardown?.call(mounted).catch(() => {}); | |
| 336 | } | |
| 337 | mounted = null; | |
| 338 | vi.unstubAllGlobals(); | |
| 339 | Reflect.deleteProperty(window, "desktopBridge"); | |
| 340 | Reflect.deleteProperty(window, "nativeApi"); | |
| 341 | document.body.innerHTML = ""; | |
| 53 | 342 | resetServerStateForTests(); |
| 54 | 343 | await __resetLocalApiForTests(); |
| 55 | document.body.innerHTML = ""; | |
| 344 | authAccessHarness.reset(); | |
| 345 | }); | |
| 346 | ||
| 347 | it("hides owner pairing tools in browser-served loopback builds without remote exposure", async () => { | |
| 348 | Reflect.deleteProperty(window, "desktopBridge"); | |
| 349 | authAccessHarness.setSnapshot({ | |
| 350 | pairingLinks: [], | |
| 351 | clientSessions: [ | |
| 352 | makeClientSession({ | |
| 353 | sessionId: "session-owner", | |
| 354 | subject: "browser-owner", | |
| 355 | role: "owner", | |
| 356 | method: "browser-session-cookie", | |
| 357 | client: { | |
| 358 | label: "Chrome on Mac", | |
| 359 | deviceType: "desktop", | |
| 360 | os: "macOS", | |
| 361 | browser: "Chrome", | |
| 362 | ipAddress: "127.0.0.1", | |
| 363 | }, | |
| 364 | issuedAt: "2026-04-07T00:00:00.000Z", | |
| 365 | expiresAt: "2026-05-07T00:00:00.000Z", | |
| 366 | connected: true, | |
| 367 | current: true, | |
| 368 | }), | |
| 369 | ], | |
| 370 | }); | |
| 371 | const fetchMock = vi.fn<typeof fetch>().mockImplementation(async (input) => { | |
| 372 | const url = String(input); | |
| 373 | if (url.endsWith("/api/auth/session")) { | |
| 374 | return new Response( | |
| 375 | JSON.stringify({ | |
| 376 | authenticated: true, | |
| 377 | auth: createBaseServerConfig().auth, | |
| 378 | role: "owner", | |
| 379 | sessionMethod: "browser-session-cookie", | |
| 380 | expiresAt: "2026-05-07T00:00:00.000Z", | |
| 381 | }), | |
| 382 | { | |
| 383 | status: 200, | |
| 384 | headers: { "content-type": "application/json" }, | |
| 385 | }, | |
| 386 | ); | |
| 387 | } | |
| 388 | ||
| 389 | throw new Error(`Unhandled fetch GET ${url}`); | |
| 390 | }); | |
| 391 | vi.stubGlobal("fetch", fetchMock); | |
| 392 | ||
| 393 | mounted = await render( | |
| 394 | <AppAtomRegistryProvider> | |
| 395 | <ConnectionsSettings /> | |
| 396 | </AppAtomRegistryProvider>, | |
| 397 | ); | |
| 398 | ||
| 399 | await expect.element(page.getByText("Manage local backend")).toBeInTheDocument(); | |
| 400 | await expect.element(page.getByLabelText("Enable network access")).toBeDisabled(); | |
| 401 | await expect | |
| 402 | .element( | |
| 403 | page.getByText( | |
| 404 | "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing.", | |
| 405 | ), | |
| 406 | ) | |
| 407 | .toBeInTheDocument(); | |
| 408 | await expect.element(page.getByText("Authorized clients")).not.toBeInTheDocument(); | |
| 409 | await expect.element(page.getByText("Chrome on Mac")).not.toBeInTheDocument(); | |
| 410 | await expect | |
| 411 | .element(page.getByRole("heading", { name: "Remote environments", exact: true })) | |
| 412 | .toBeInTheDocument(); | |
| 56 | 413 | }); |
| 57 | 414 | |
| 58 | 415 | it("shows diagnostics inside About with a single logs-folder action", async () => { |
| 59 | 416 | setServerConfigSnapshot(createBaseServerConfig()); |
| 60 | 417 | |
| 61 | await render( | |
| 418 | mounted = await render( | |
| 62 | 419 | <AppAtomRegistryProvider> |
| 63 | 420 | <GeneralSettingsPanel /> |
| 64 | 421 | </AppAtomRegistryProvider>, |
| ... | ... | @@ -79,6 +436,233 @@ describe("GeneralSettingsPanel observability", () => { |
| 79 | 436 | .toBeInTheDocument(); |
| 80 | 437 | }); |
| 81 | 438 | |
| 439 | it("creates and shows a pairing link when network access is enabled", async () => { | |
| 440 | window.desktopBridge = createDesktopBridgeStub({ | |
| 441 | serverExposureState: { | |
| 442 | mode: "network-accessible", | |
| 443 | endpointUrl: "http://192.168.1.44:3773", | |
| 444 | advertisedHost: "192.168.1.44", | |
| 445 | }, | |
| 446 | }); | |
| 447 | let pairingLinks: Array<AuthAccessSnapshot["pairingLinks"][number]> = []; | |
| 448 | let clientSessions: Array<AuthAccessSnapshot["clientSessions"][number]> = [ | |
| 449 | makeClientSession({ | |
| 450 | sessionId: "session-owner", | |
| 451 | subject: "desktop-bootstrap", | |
| 452 | role: "owner", | |
| 453 | method: "browser-session-cookie", | |
| 454 | client: { | |
| 455 | label: "This Mac", | |
| 456 | deviceType: "desktop", | |
| 457 | os: "macOS", | |
| 458 | browser: "Electron", | |
| 459 | ipAddress: "127.0.0.1", | |
| 460 | }, | |
| 461 | issuedAt: "2026-04-07T00:00:00.000Z", | |
| 462 | expiresAt: "2026-05-07T00:00:00.000Z", | |
| 463 | connected: true, | |
| 464 | current: true, | |
| 465 | }), | |
| 466 | ]; | |
| 467 | authAccessHarness.setSnapshot({ | |
| 468 | pairingLinks, | |
| 469 | clientSessions, | |
| 470 | }); | |
| 471 | vi.stubGlobal( | |
| 472 | "fetch", | |
| 473 | vi.fn<typeof fetch>().mockImplementation(async (input, init) => { | |
| 474 | const url = String(input); | |
| 475 | const method = init?.method ?? "GET"; | |
| 476 | if (url.endsWith("/api/auth/pairing-token") && method === "POST") { | |
| 477 | pairingLinks = [ | |
| 478 | makePairingLink({ | |
| 479 | id: "pairing-link-1", | |
| 480 | credential: "pairing-token", | |
| 481 | role: "client", | |
| 482 | subject: "one-time-token", | |
| 483 | label: "Julius iPhone", | |
| 484 | createdAt: "2026-04-07T00:00:00.000Z", | |
| 485 | expiresAt: "2026-04-10T00:05:00.000Z", | |
| 486 | }), | |
| 487 | ]; | |
| 488 | clientSessions = [ | |
| 489 | ...clientSessions, | |
| 490 | makeClientSession({ | |
| 491 | sessionId: "session-client", | |
| 492 | subject: "one-time-token", | |
| 493 | role: "client", | |
| 494 | method: "browser-session-cookie", | |
| 495 | client: { | |
| 496 | label: "Julius iPhone", | |
| 497 | deviceType: "mobile", | |
| 498 | os: "iOS", | |
| 499 | browser: "Safari", | |
| 500 | ipAddress: "192.168.1.88", | |
| 501 | }, | |
| 502 | issuedAt: "2026-04-07T00:01:00.000Z", | |
| 503 | expiresAt: "2026-05-07T00:01:00.000Z", | |
| 504 | connected: false, | |
| 505 | current: false, | |
| 506 | }), | |
| 507 | ]; | |
| 508 | authAccessHarness.setSnapshot({ | |
| 509 | pairingLinks, | |
| 510 | clientSessions, | |
| 511 | }); | |
| 512 | return new Response( | |
| 513 | JSON.stringify({ | |
| 514 | id: "pairing-link-1", | |
| 515 | credential: "pairing-token", | |
| 516 | label: "Julius iPhone", | |
| 517 | expiresAt: "2026-04-10T00:05:00.000Z", | |
| 518 | }), | |
| 519 | { | |
| 520 | status: 200, | |
| 521 | headers: { "content-type": "application/json" }, | |
| 522 | }, | |
| 523 | ); | |
| 524 | } | |
| 525 | ||
| 526 | throw new Error(`Unhandled fetch ${method} ${url}`); | |
| 527 | }), | |
| 528 | ); | |
| 529 | ||
| 530 | setServerConfigSnapshot(createBaseServerConfig()); | |
| 531 | ||
| 532 | mounted = await render( | |
| 533 | <AppAtomRegistryProvider> | |
| 534 | <ConnectionsSettings /> | |
| 535 | </AppAtomRegistryProvider>, | |
| 536 | ); | |
| 537 | ||
| 538 | await expect.element(page.getByText("Authorized clients")).toBeInTheDocument(); | |
| 539 | await expect.element(page.getByText("Revoke others")).toBeInTheDocument(); | |
| 540 | await expect.element(page.getByText("This Mac")).toBeInTheDocument(); | |
| 541 | await page.getByRole("button", { name: "Create link", exact: true }).click(); | |
| 542 | await expect.element(page.getByText("Create pairing link")).toBeInTheDocument(); | |
| 543 | await page.getByRole("button", { name: "Create link", exact: true }).click(); | |
| 544 | authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!); | |
| 545 | authAccessHarness.emitClientUpserted(clientSessions[1]!); | |
| 546 | await expect | |
| 547 | .element(page.getByText("Client · Mobile · iOS · Safari · 192.168.1.88")) | |
| 548 | .toBeInTheDocument(); | |
| 549 | await expect | |
| 550 | .element(page.getByRole("button", { name: "Copy", exact: true })) | |
| 551 | .toBeInTheDocument(); | |
| 552 | await expect.element(page.getByText("Revoke others")).toBeInTheDocument(); | |
| 553 | }); | |
| 554 | ||
| 555 | it("revokes all other paired clients from settings", async () => { | |
| 556 | window.desktopBridge = createDesktopBridgeStub({ | |
| 557 | serverExposureState: { | |
| 558 | mode: "network-accessible", | |
| 559 | endpointUrl: "http://192.168.1.44:3773", | |
| 560 | advertisedHost: "192.168.1.44", | |
| 561 | }, | |
| 562 | }); | |
| 563 | let clientSessions: Array<AuthAccessSnapshot["clientSessions"][number]> = [ | |
| 564 | makeClientSession({ | |
| 565 | sessionId: "session-owner", | |
| 566 | subject: "desktop-bootstrap", | |
| 567 | role: "owner", | |
| 568 | method: "browser-session-cookie", | |
| 569 | client: { | |
| 570 | label: "This Mac", | |
| 571 | deviceType: "desktop", | |
| 572 | os: "macOS", | |
| 573 | browser: "Electron", | |
| 574 | }, | |
| 575 | issuedAt: "2026-04-05T00:00:00.000Z", | |
| 576 | expiresAt: "2026-05-05T00:00:00.000Z", | |
| 577 | connected: true, | |
| 578 | current: true, | |
| 579 | }), | |
| 580 | makeClientSession({ | |
| 581 | sessionId: "session-client", | |
| 582 | subject: "one-time-token", | |
| 583 | role: "client", | |
| 584 | method: "browser-session-cookie", | |
| 585 | client: { | |
| 586 | label: "Julius iPhone", | |
| 587 | deviceType: "mobile", | |
| 588 | os: "iOS", | |
| 589 | browser: "Safari", | |
| 590 | ipAddress: "192.168.1.88", | |
| 591 | }, | |
| 592 | issuedAt: "2026-04-05T00:01:00.000Z", | |
| 593 | expiresAt: "2026-05-05T00:01:00.000Z", | |
| 594 | connected: false, | |
| 595 | current: false, | |
| 596 | }), | |
| 597 | ]; | |
| 598 | authAccessHarness.setSnapshot({ | |
| 599 | pairingLinks: [], | |
| 600 | clientSessions, | |
| 601 | }); | |
| 602 | ||
| 603 | const fetchMock = vi.fn<typeof fetch>().mockImplementation(async (input, init) => { | |
| 604 | const url = String(input); | |
| 605 | const method = init?.method ?? "GET"; | |
| 606 | if (url.endsWith("/api/auth/clients/revoke-others") && method === "POST") { | |
| 607 | clientSessions = clientSessions.filter((session) => session.current); | |
| 608 | authAccessHarness.setSnapshot({ | |
| 609 | pairingLinks: [], | |
| 610 | clientSessions, | |
| 611 | }); | |
| 612 | authAccessHarness.emitClientRemoved("session-client"); | |
| 613 | return new Response(JSON.stringify({ revokedCount: 1 }), { | |
| 614 | status: 200, | |
| 615 | headers: { "content-type": "application/json" }, | |
| 616 | }); | |
| 617 | } | |
| 618 | ||
| 619 | throw new Error(`Unhandled fetch ${method} ${url}`); | |
| 620 | }); | |
| 621 | vi.stubGlobal("fetch", fetchMock); | |
| 622 | ||
| 623 | setServerConfigSnapshot(createBaseServerConfig()); | |
| 624 | ||
| 625 | mounted = await render( | |
| 626 | <AppAtomRegistryProvider> | |
| 627 | <ConnectionsSettings /> | |
| 628 | </AppAtomRegistryProvider>, | |
| 629 | ); | |
| 630 | ||
| 631 | await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument(); | |
| 632 | await page.getByRole("button", { name: "Revoke others", exact: true }).click(); | |
| 633 | await expect.element(page.getByText("This Mac")).toBeInTheDocument(); | |
| 634 | await expect.element(page.getByText("Julius iPhone")).not.toBeInTheDocument(); | |
| 635 | expect(fetchMock).toHaveBeenCalled(); | |
| 636 | }); | |
| 637 | ||
| 638 | it("shows a disabled network access toggle with guidance in desktop builds", async () => { | |
| 639 | const desktopBridge = createDesktopBridgeStub(); | |
| 640 | window.desktopBridge = desktopBridge; | |
| 641 | ||
| 642 | setServerConfigSnapshot(createBaseServerConfig()); | |
| 643 | ||
| 644 | mounted = await render( | |
| 645 | <AppAtomRegistryProvider> | |
| 646 | <ConnectionsSettings /> | |
| 647 | </AppAtomRegistryProvider>, | |
| 648 | ); | |
| 649 | ||
| 650 | const networkAccessToggle = page.getByLabelText("Enable network access"); | |
| 651 | await expect.element(networkAccessToggle).not.toBeDisabled(); | |
| 652 | await networkAccessToggle.click(); | |
| 653 | await expect.element(page.getByText("Enable network access?")).toBeInTheDocument(); | |
| 654 | await expect | |
| 655 | .element(page.getByText("T3 Code will restart to expose this environment over the network.")) | |
| 656 | .toBeInTheDocument(); | |
| 657 | await page.getByRole("button", { name: "Restart and enable", exact: true }).click(); | |
| 658 | await vi.waitFor(() => { | |
| 659 | expect(desktopBridge.setServerExposureMode).toHaveBeenCalledWith("network-accessible"); | |
| 660 | }); | |
| 661 | await expect | |
| 662 | .element(page.getByText("Reachable at http://192.168.1.44:3773")) | |
| 663 | .toBeInTheDocument(); | |
| 664 | }); | |
| 665 | ||
| 82 | 666 | it("opens the logs folder in the preferred editor", async () => { |
| 83 | 667 | const openInEditor = vi.fn<LocalApi["shell"]["openInEditor"]>().mockResolvedValue(undefined); |
| 84 | 668 | window.nativeApi = { |
| ... | ... | @@ -89,7 +673,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 89 | 673 | |
| 90 | 674 | setServerConfigSnapshot(createBaseServerConfig()); |
| 91 | 675 | |
| 92 | await render( | |
| 676 | mounted = await render( | |
| 93 | 677 | <AppAtomRegistryProvider> |
| 94 | 678 | <GeneralSettingsPanel /> |
| 95 | 679 | </AppAtomRegistryProvider>, |
apps/web/src/components/settings/SettingsPanels.tsx+14-113| ... | ... | @@ -6,11 +6,10 @@ import { |
| 6 | 6 | LoaderIcon, |
| 7 | 7 | PlusIcon, |
| 8 | 8 | RefreshCwIcon, |
| 9 | Undo2Icon, | |
| 10 | 9 | XIcon, |
| 11 | 10 | } from "lucide-react"; |
| 12 | 11 | import { useQueryClient } from "@tanstack/react-query"; |
| 13 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 12 | import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; | |
| 14 | 13 | import { |
| 15 | 14 | PROVIDER_DISPLAY_NAMES, |
| 16 | 15 | type ScopedThreadRef, |
| ... | ... | @@ -47,9 +46,10 @@ import { |
| 47 | 46 | resolveAppModelSelectionState, |
| 48 | 47 | } from "../../modelSelection"; |
| 49 | 48 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 49 | import { useShallow } from "zustand/react/shallow"; | |
| 50 | 50 | import { |
| 51 | 51 | selectProjectsAcrossEnvironments, |
| 52 | selectThreadsAcrossEnvironments, | |
| 52 | selectThreadShellsAcrossEnvironments, | |
| 53 | 53 | useStore, |
| 54 | 54 | } from "../../store"; |
| 55 | 55 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; |
| ... | ... | @@ -62,6 +62,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ |
| 62 | 62 | import { Switch } from "../ui/switch"; |
| 63 | 63 | import { toastManager } from "../ui/toast"; |
| 64 | 64 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 65 | import { | |
| 66 | SettingResetButton, | |
| 67 | SettingsPageContainer, | |
| 68 | SettingsRow, | |
| 69 | SettingsSection, | |
| 70 | useRelativeTimeTick, | |
| 71 | } from "./settingsLayout"; | |
| 65 | 72 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 66 | 73 | import { |
| 67 | 74 | useServerAvailableEditors, |
| ... | ... | @@ -191,15 +198,6 @@ function getProviderVersionLabel(version: string | null | undefined) { |
| 191 | 198 | return version.startsWith("v") ? version : `v${version}`; |
| 192 | 199 | } |
| 193 | 200 | |
| 194 | function useRelativeTimeTick(intervalMs = 1_000) { | |
| 195 | const [tick, setTick] = useState(() => Date.now()); | |
| 196 | useEffect(() => { | |
| 197 | const id = setInterval(() => setTick(Date.now()), intervalMs); | |
| 198 | return () => clearInterval(id); | |
| 199 | }, [intervalMs]); | |
| 200 | return tick; | |
| 201 | } | |
| 202 | ||
| 203 | 201 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { |
| 204 | 202 | useRelativeTimeTick(); |
| 205 | 203 | const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; |
| ... | ... | @@ -222,104 +220,6 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } |
| 222 | 220 | ); |
| 223 | 221 | } |
| 224 | 222 | |
| 225 | function SettingsSection({ | |
| 226 | title, | |
| 227 | icon, | |
| 228 | headerAction, | |
| 229 | children, | |
| 230 | }: { | |
| 231 | title: string; | |
| 232 | icon?: ReactNode; | |
| 233 | headerAction?: ReactNode; | |
| 234 | children: ReactNode; | |
| 235 | }) { | |
| 236 | return ( | |
| 237 | <section className="space-y-3"> | |
| 238 | <div className="flex items-center justify-between"> | |
| 239 | <h2 className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground"> | |
| 240 | {icon} | |
| 241 | {title} | |
| 242 | </h2> | |
| 243 | {headerAction} | |
| 244 | </div> | |
| 245 | <div className="relative overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-xs/5 not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]"> | |
| 246 | {children} | |
| 247 | </div> | |
| 248 | </section> | |
| 249 | ); | |
| 250 | } | |
| 251 | ||
| 252 | function SettingsRow({ | |
| 253 | title, | |
| 254 | description, | |
| 255 | status, | |
| 256 | resetAction, | |
| 257 | control, | |
| 258 | children, | |
| 259 | }: { | |
| 260 | title: ReactNode; | |
| 261 | description: string; | |
| 262 | status?: ReactNode; | |
| 263 | resetAction?: ReactNode; | |
| 264 | control?: ReactNode; | |
| 265 | children?: ReactNode; | |
| 266 | }) { | |
| 267 | return ( | |
| 268 | <div className="border-t border-border px-4 py-4 first:border-t-0 sm:px-5"> | |
| 269 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 270 | <div className="min-w-0 flex-1 space-y-1"> | |
| 271 | <div className="flex min-h-5 items-center gap-1.5"> | |
| 272 | <h3 className="text-sm font-medium text-foreground">{title}</h3> | |
| 273 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | |
| 274 | {resetAction} | |
| 275 | </span> | |
| 276 | </div> | |
| 277 | <p className="text-xs text-muted-foreground">{description}</p> | |
| 278 | {status ? <div className="pt-1 text-[11px] text-muted-foreground">{status}</div> : null} | |
| 279 | </div> | |
| 280 | {control ? ( | |
| 281 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | |
| 282 | {control} | |
| 283 | </div> | |
| 284 | ) : null} | |
| 285 | </div> | |
| 286 | {children} | |
| 287 | </div> | |
| 288 | ); | |
| 289 | } | |
| 290 | ||
| 291 | function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { | |
| 292 | return ( | |
| 293 | <Tooltip> | |
| 294 | <TooltipTrigger | |
| 295 | render={ | |
| 296 | <Button | |
| 297 | size="icon-xs" | |
| 298 | variant="ghost" | |
| 299 | aria-label={`Reset ${label} to default`} | |
| 300 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 301 | onClick={(event) => { | |
| 302 | event.stopPropagation(); | |
| 303 | onClick(); | |
| 304 | }} | |
| 305 | > | |
| 306 | <Undo2Icon className="size-3" /> | |
| 307 | </Button> | |
| 308 | } | |
| 309 | /> | |
| 310 | <TooltipPopup side="top">Reset to default</TooltipPopup> | |
| 311 | </Tooltip> | |
| 312 | ); | |
| 313 | } | |
| 314 | ||
| 315 | function SettingsPageContainer({ children }: { children: ReactNode }) { | |
| 316 | return ( | |
| 317 | <div className="flex-1 overflow-y-auto p-6"> | |
| 318 | <div className="mx-auto flex w-full max-w-4xl flex-col gap-6">{children}</div> | |
| 319 | </div> | |
| 320 | ); | |
| 321 | } | |
| 322 | ||
| 323 | 223 | function AboutVersionTitle() { |
| 324 | 224 | return ( |
| 325 | 225 | <span className="inline-flex items-center gap-2"> |
| ... | ... | @@ -781,6 +681,7 @@ export function GeneralSettingsPanel() { |
| 781 | 681 | serverProviders[0]!.checkedAt, |
| 782 | 682 | ) |
| 783 | 683 | : null; |
| 684 | ||
| 784 | 685 | return ( |
| 785 | 686 | <SettingsPageContainer> |
| 786 | 687 | <SettingsSection title="General"> |
| ... | ... | @@ -1484,8 +1385,8 @@ export function GeneralSettingsPanel() { |
| 1484 | 1385 | } |
| 1485 | 1386 | |
| 1486 | 1387 | export function ArchivedThreadsPanel() { |
| 1487 | const projects = useStore(selectProjectsAcrossEnvironments); | |
| 1488 | const threads = useStore(selectThreadsAcrossEnvironments); | |
| 1388 | const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); | |
| 1389 | const threads = useStore(useShallow(selectThreadShellsAcrossEnvironments)); | |
| 1489 | 1390 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); |
| 1490 | 1391 | const archivedGroups = useMemo(() => { |
| 1491 | 1392 | return projects |
| ... | ... | @@ -1553,7 +1454,7 @@ export function ArchivedThreadsPanel() { |
| 1553 | 1454 | <SettingsSection |
| 1554 | 1455 | key={project.id} |
| 1555 | 1456 | title={project.name} |
| 1556 | icon={<ProjectFavicon cwd={project.cwd} />} | |
| 1457 | icon={<ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />} | |
| 1557 | 1458 | > |
| 1558 | 1459 | {projectThreads.map((thread) => ( |
| 1559 | 1460 | <div |
apps/web/src/components/settings/SettingsSidebarNav.tsx+9-5| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { ComponentType } from "react"; |
| 2 | import { ArchiveIcon, ArrowLeftIcon, Settings2Icon } from "lucide-react"; | |
| 2 | import { ArchiveIcon, ArrowLeftIcon, Link2Icon, Settings2Icon } from "lucide-react"; | |
| 3 | 3 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | 4 | |
| 5 | 5 | import { |
| ... | ... | @@ -12,7 +12,10 @@ import { |
| 12 | 12 | SidebarSeparator, |
| 13 | 13 | } from "../ui/sidebar"; |
| 14 | 14 | |
| 15 | export type SettingsSectionPath = "/settings/general" | "/settings/archived"; | |
| 15 | export type SettingsSectionPath = | |
| 16 | | "/settings/general" | |
| 17 | | "/settings/connections" | |
| 18 | | "/settings/archived"; | |
| 16 | 19 | |
| 17 | 20 | export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 18 | 21 | label: string; |
| ... | ... | @@ -20,6 +23,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 20 | 23 | icon: ComponentType<{ className?: string }>; |
| 21 | 24 | }> = [ |
| 22 | 25 | { label: "General", to: "/settings/general", icon: Settings2Icon }, |
| 26 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, | |
| 23 | 27 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, |
| 24 | 28 | ]; |
| 25 | 29 | |
| ... | ... | @@ -41,8 +45,8 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 41 | 45 | isActive={isActive} |
| 42 | 46 | className={ |
| 43 | 47 | isActive |
| 44 | ? "gap-2 px-2 py-2 text-left text-xs text-foreground" | |
| 45 | : "gap-2 px-2 py-2 text-left text-xs text-muted-foreground hover:text-foreground/80" | |
| 48 | ? "gap-2.5 px-2.5 py-2 text-left text-[13px] font-medium text-foreground" | |
| 49 | : "gap-2.5 px-2.5 py-2 text-left text-[13px] text-muted-foreground/70 hover:text-foreground/80" | |
| 46 | 50 | } |
| 47 | 51 | onClick={() => void navigate({ to: item.to, replace: true })} |
| 48 | 52 | > |
| ... | ... | @@ -50,7 +54,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 50 | 54 | className={ |
| 51 | 55 | isActive |
| 52 | 56 | ? "size-4 shrink-0 text-foreground" |
| 53 | : "size-4 shrink-0 text-muted-foreground" | |
| 57 | : "size-4 shrink-0 text-muted-foreground/60" | |
| 54 | 58 | } |
| 55 | 59 | /> |
| 56 | 60 | <span className="truncate">{item.label}</span> |
apps/web/src/components/settings/settingsLayout.tsx created+122| ... | ... | @@ -0,0 +1,122 @@ |
| 1 | import { Undo2Icon } from "lucide-react"; | |
| 2 | import { type ReactNode, useEffect, useState } from "react"; | |
| 3 | ||
| 4 | import { cn } from "../../lib/utils"; | |
| 5 | import { Button } from "../ui/button"; | |
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 7 | ||
| 8 | /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ | |
| 9 | export function useRelativeTimeTick(intervalMs = 1_000) { | |
| 10 | const [nowMs, setNowMs] = useState(() => Date.now()); | |
| 11 | useEffect(() => { | |
| 12 | const id = setInterval(() => setNowMs(Date.now()), intervalMs); | |
| 13 | return () => clearInterval(id); | |
| 14 | }, [intervalMs]); | |
| 15 | return nowMs; | |
| 16 | } | |
| 17 | ||
| 18 | export function SettingsSection({ | |
| 19 | title, | |
| 20 | icon, | |
| 21 | headerAction, | |
| 22 | children, | |
| 23 | }: { | |
| 24 | title: string; | |
| 25 | icon?: ReactNode; | |
| 26 | headerAction?: ReactNode; | |
| 27 | children: ReactNode; | |
| 28 | }) { | |
| 29 | return ( | |
| 30 | <section className="space-y-2.5"> | |
| 31 | <div className="flex items-center justify-between px-1"> | |
| 32 | <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50"> | |
| 33 | <span className="inline-block h-px w-3 bg-border" aria-hidden /> | |
| 34 | {icon} | |
| 35 | {title} | |
| 36 | </h2> | |
| 37 | {headerAction} | |
| 38 | </div> | |
| 39 | <div className="relative overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm/4 not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:shadow-none dark:before:shadow-[0_-1px_--theme(--color-white/6%)]"> | |
| 40 | {children} | |
| 41 | </div> | |
| 42 | </section> | |
| 43 | ); | |
| 44 | } | |
| 45 | ||
| 46 | export function SettingsRow({ | |
| 47 | title, | |
| 48 | description, | |
| 49 | status, | |
| 50 | resetAction, | |
| 51 | control, | |
| 52 | children, | |
| 53 | }: { | |
| 54 | title: ReactNode; | |
| 55 | description: string; | |
| 56 | status?: ReactNode; | |
| 57 | resetAction?: ReactNode; | |
| 58 | control?: ReactNode; | |
| 59 | children?: ReactNode; | |
| 60 | }) { | |
| 61 | return ( | |
| 62 | <div | |
| 63 | className={cn( | |
| 64 | "border-t border-border/60 px-4 first:border-t-0 sm:px-5", | |
| 65 | children ? "pt-4 pb-0" : "py-4", | |
| 66 | )} | |
| 67 | > | |
| 68 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 69 | <div className="min-w-0 flex-1 space-y-1"> | |
| 70 | <div className="flex min-h-5 items-center gap-1.5"> | |
| 71 | <h3 className="text-[13px] font-semibold tracking-[-0.01em] text-foreground"> | |
| 72 | {title} | |
| 73 | </h3> | |
| 74 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | |
| 75 | {resetAction} | |
| 76 | </span> | |
| 77 | </div> | |
| 78 | <p className="text-xs leading-relaxed text-muted-foreground/80">{description}</p> | |
| 79 | {status ? <div className="pt-0.5 text-[11px] text-muted-foreground">{status}</div> : null} | |
| 80 | </div> | |
| 81 | {control ? ( | |
| 82 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | |
| 83 | {control} | |
| 84 | </div> | |
| 85 | ) : null} | |
| 86 | </div> | |
| 87 | {children} | |
| 88 | </div> | |
| 89 | ); | |
| 90 | } | |
| 91 | ||
| 92 | export function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { | |
| 93 | return ( | |
| 94 | <Tooltip> | |
| 95 | <TooltipTrigger | |
| 96 | render={ | |
| 97 | <Button | |
| 98 | size="icon-xs" | |
| 99 | variant="ghost" | |
| 100 | aria-label={`Reset ${label} to default`} | |
| 101 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 102 | onClick={(event) => { | |
| 103 | event.stopPropagation(); | |
| 104 | onClick(); | |
| 105 | }} | |
| 106 | > | |
| 107 | <Undo2Icon className="size-3" /> | |
| 108 | </Button> | |
| 109 | } | |
| 110 | /> | |
| 111 | <TooltipPopup side="top">Reset to default</TooltipPopup> | |
| 112 | </Tooltip> | |
| 113 | ); | |
| 114 | } | |
| 115 | ||
| 116 | export function SettingsPageContainer({ children }: { children: ReactNode }) { | |
| 117 | return ( | |
| 118 | <div className="flex-1 overflow-y-auto p-6 sm:p-8"> | |
| 119 | <div className="mx-auto flex w-full max-w-3xl flex-col gap-8">{children}</div> | |
| 120 | </div> | |
| 121 | ); | |
| 122 | } |
apps/web/src/components/ui/qr-code.test.tsx created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { QRCodeSvg } from "./qr-code"; | |
| 5 | ||
| 6 | describe("QRCodeSvg", () => { | |
| 7 | it("renders with explicit high-contrast colors by default", () => { | |
| 8 | const markup = renderToStaticMarkup(<QRCodeSvg value="https://example.com/pair" />); | |
| 9 | ||
| 10 | expect(markup).toContain('fill="#fff"'); | |
| 11 | expect(markup).toContain('fill="#000"'); | |
| 12 | expect(markup).not.toContain('fill="currentColor"'); | |
| 13 | }); | |
| 14 | ||
| 15 | it("supports custom foreground and background colors", () => { | |
| 16 | const markup = renderToStaticMarkup( | |
| 17 | <QRCodeSvg | |
| 18 | value="https://example.com/pair" | |
| 19 | foregroundColor="#123456" | |
| 20 | backgroundColor="#abcdef" | |
| 21 | />, | |
| 22 | ); | |
| 23 | ||
| 24 | expect(markup).toContain('fill="#abcdef"'); | |
| 25 | expect(markup).toContain('fill="#123456"'); | |
| 26 | }); | |
| 27 | }); |
apps/web/src/components/ui/qr-code.tsx created+81| ... | ... | @@ -0,0 +1,81 @@ |
| 1 | import { memo } from "react"; | |
| 2 | import { QrCode } from "../../lib/vendor/qrcodegen"; | |
| 3 | ||
| 4 | type QRCodeSvgProps = { | |
| 5 | value: string; | |
| 6 | size?: number; | |
| 7 | level?: "L" | "M" | "Q" | "H"; | |
| 8 | marginSize?: number; | |
| 9 | title?: string; | |
| 10 | className?: string; | |
| 11 | foregroundColor?: string; | |
| 12 | backgroundColor?: string; | |
| 13 | }; | |
| 14 | ||
| 15 | const ERROR_CORRECTION_LEVELS = { | |
| 16 | L: QrCode.Ecc.LOW, | |
| 17 | M: QrCode.Ecc.MEDIUM, | |
| 18 | Q: QrCode.Ecc.QUARTILE, | |
| 19 | H: QrCode.Ecc.HIGH, | |
| 20 | } as const; | |
| 21 | ||
| 22 | function buildQrPathData(qrCode: ReturnType<typeof QrCode.encodeText>, marginSize: number): string { | |
| 23 | const commands: Array<string> = []; | |
| 24 | ||
| 25 | for (let y = 0; y < qrCode.size; y += 1) { | |
| 26 | let runStart = -1; | |
| 27 | ||
| 28 | for (let x = 0; x <= qrCode.size; x += 1) { | |
| 29 | const isDark = x < qrCode.size && qrCode.getModule(x, y); | |
| 30 | ||
| 31 | if (isDark) { | |
| 32 | if (runStart === -1) { | |
| 33 | runStart = x; | |
| 34 | } | |
| 35 | continue; | |
| 36 | } | |
| 37 | ||
| 38 | if (runStart === -1) { | |
| 39 | continue; | |
| 40 | } | |
| 41 | ||
| 42 | commands.push( | |
| 43 | `M${runStart + marginSize} ${y + marginSize}h${x - runStart}v1H${runStart + marginSize}z`, | |
| 44 | ); | |
| 45 | runStart = -1; | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | return commands.join(""); | |
| 50 | } | |
| 51 | ||
| 52 | export const QRCodeSvg = memo(function QRCodeSvg({ | |
| 53 | value, | |
| 54 | size = 128, | |
| 55 | level = "L", | |
| 56 | marginSize = 0, | |
| 57 | title, | |
| 58 | className, | |
| 59 | foregroundColor = "#000", | |
| 60 | backgroundColor = "#fff", | |
| 61 | }: QRCodeSvgProps) { | |
| 62 | const qrCode = QrCode.encodeText(value, ERROR_CORRECTION_LEVELS[level]); | |
| 63 | const viewBoxSize = qrCode.size + marginSize * 2; | |
| 64 | ||
| 65 | return ( | |
| 66 | <svg | |
| 67 | xmlns="http://www.w3.org/2000/svg" | |
| 68 | viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`} | |
| 69 | width={size} | |
| 70 | height={size} | |
| 71 | shapeRendering="crispEdges" | |
| 72 | role={title ? "img" : undefined} | |
| 73 | aria-label={title} | |
| 74 | className={className} | |
| 75 | > | |
| 76 | {title ? <title>{title}</title> : null} | |
| 77 | <rect width={viewBoxSize} height={viewBoxSize} fill={backgroundColor} /> | |
| 78 | <path d={buildQrPathData(qrCode, marginSize)} fill={foregroundColor} /> | |
| 79 | </svg> | |
| 80 | ); | |
| 81 | }); |
apps/web/src/composerDraftStore.test.ts+44| ... | ... | @@ -800,6 +800,50 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 800 | 800 | envMode: "worktree", |
| 801 | 801 | }); |
| 802 | 802 | }); |
| 803 | ||
| 804 | it("clears branch and worktree context when remapping a draft to another environment", () => { | |
| 805 | const store = useComposerDraftStore.getState(); | |
| 806 | store.setProjectDraftThreadId(projectRef, draftId, { | |
| 807 | threadId, | |
| 808 | branch: "feature/local-only", | |
| 809 | worktreePath: "/tmp/local-worktree", | |
| 810 | envMode: "worktree", | |
| 811 | }); | |
| 812 | ||
| 813 | store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), remoteProjectRef, draftId, { | |
| 814 | threadId, | |
| 815 | }); | |
| 816 | ||
| 817 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ | |
| 818 | environmentId: OTHER_TEST_ENVIRONMENT_ID, | |
| 819 | projectId, | |
| 820 | branch: null, | |
| 821 | worktreePath: null, | |
| 822 | envMode: "local", | |
| 823 | }); | |
| 824 | }); | |
| 825 | ||
| 826 | it("clears branch and worktree context when changing a draft thread project ref", () => { | |
| 827 | const store = useComposerDraftStore.getState(); | |
| 828 | store.setProjectDraftThreadId(projectRef, draftId, { | |
| 829 | threadId, | |
| 830 | branch: "feature/local-only", | |
| 831 | worktreePath: "/tmp/local-worktree", | |
| 832 | envMode: "worktree", | |
| 833 | }); | |
| 834 | ||
| 835 | store.setDraftThreadContext(draftId, { | |
| 836 | projectRef: remoteProjectRef, | |
| 837 | }); | |
| 838 | ||
| 839 | expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ | |
| 840 | environmentId: OTHER_TEST_ENVIRONMENT_ID, | |
| 841 | projectId, | |
| 842 | branch: null, | |
| 843 | worktreePath: null, | |
| 844 | envMode: "local", | |
| 845 | }); | |
| 846 | }); | |
| 803 | 847 | }); |
| 804 | 848 | |
| 805 | 849 | describe("composerDraftStore modelSelection", () => { |
apps/web/src/composerDraftStore.ts+39-7| ... | ... | @@ -1001,10 +1001,22 @@ function createDraftThreadState( |
| 1001 | 1001 | interactionMode?: ProviderInteractionMode; |
| 1002 | 1002 | }, |
| 1003 | 1003 | ): DraftThreadState { |
| 1004 | const projectChanged = | |
| 1005 | existingThread !== undefined && | |
| 1006 | (existingThread.environmentId !== projectRef.environmentId || | |
| 1007 | existingThread.projectId !== projectRef.projectId); | |
| 1004 | 1008 | const nextWorktreePath = |
| 1005 | 1009 | options?.worktreePath === undefined |
| 1006 | ? (existingThread?.worktreePath ?? null) | |
| 1010 | ? projectChanged | |
| 1011 | ? null | |
| 1012 | : (existingThread?.worktreePath ?? null) | |
| 1007 | 1013 | : (options.worktreePath ?? null); |
| 1014 | const nextBranch = | |
| 1015 | options?.branch === undefined | |
| 1016 | ? projectChanged | |
| 1017 | ? null | |
| 1018 | : (existingThread?.branch ?? null) | |
| 1019 | : (options.branch ?? null); | |
| 1008 | 1020 | return { |
| 1009 | 1021 | threadId, |
| 1010 | 1022 | environmentId: projectRef.environmentId, |
| ... | ... | @@ -1014,11 +1026,15 @@ function createDraftThreadState( |
| 1014 | 1026 | runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, |
| 1015 | 1027 | interactionMode: |
| 1016 | 1028 | options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, |
| 1017 | branch: | |
| 1018 | options?.branch === undefined ? (existingThread?.branch ?? null) : (options.branch ?? null), | |
| 1029 | branch: nextBranch, | |
| 1019 | 1030 | worktreePath: nextWorktreePath, |
| 1020 | 1031 | envMode: |
| 1021 | options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), | |
| 1032 | options?.envMode ?? | |
| 1033 | (nextWorktreePath | |
| 1034 | ? "worktree" | |
| 1035 | : projectChanged | |
| 1036 | ? "local" | |
| 1037 | : (existingThread?.envMode ?? "local")), | |
| 1022 | 1038 | promotedTo: null, |
| 1023 | 1039 | }; |
| 1024 | 1040 | } |
| ... | ... | @@ -1908,10 +1924,21 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 1908 | 1924 | ) { |
| 1909 | 1925 | return state; |
| 1910 | 1926 | } |
| 1927 | const projectChanged = | |
| 1928 | nextProjectRef.environmentId !== existing.environmentId || | |
| 1929 | nextProjectRef.projectId !== existing.projectId; | |
| 1911 | 1930 | const nextWorktreePath = |
| 1912 | 1931 | options.worktreePath === undefined |
| 1913 | ? existing.worktreePath | |
| 1932 | ? projectChanged | |
| 1933 | ? null | |
| 1934 | : existing.worktreePath | |
| 1914 | 1935 | : (options.worktreePath ?? null); |
| 1936 | const nextBranch = | |
| 1937 | options.branch === undefined | |
| 1938 | ? projectChanged | |
| 1939 | ? null | |
| 1940 | : existing.branch | |
| 1941 | : (options.branch ?? null); | |
| 1915 | 1942 | const nextDraftThread: DraftThreadState = { |
| 1916 | 1943 | threadId: existing.threadId, |
| 1917 | 1944 | environmentId: nextProjectRef.environmentId, |
| ... | ... | @@ -1923,10 +1950,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 1923 | 1950 | : options.createdAt || existing.createdAt, |
| 1924 | 1951 | runtimeMode: options.runtimeMode ?? existing.runtimeMode, |
| 1925 | 1952 | interactionMode: options.interactionMode ?? existing.interactionMode, |
| 1926 | branch: options.branch === undefined ? existing.branch : (options.branch ?? null), | |
| 1953 | branch: nextBranch, | |
| 1927 | 1954 | worktreePath: nextWorktreePath, |
| 1928 | 1955 | envMode: |
| 1929 | options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), | |
| 1956 | options.envMode ?? | |
| 1957 | (nextWorktreePath | |
| 1958 | ? "worktree" | |
| 1959 | : projectChanged | |
| 1960 | ? "local" | |
| 1961 | : (existing.envMode ?? "local")), | |
| 1930 | 1962 | promotedTo: existing.promotedTo ?? null, |
| 1931 | 1963 | }; |
| 1932 | 1964 | const isUnchanged = |
apps/web/src/environmentApi.ts+4-3| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | import { readWsRpcClientEntryForEnvironment, WsRpcClient } from "./wsRpcClient"; | |
| 3 | import type { WsRpcClient } from "./rpc/wsRpcClient"; | |
| 4 | import { readEnvironmentConnection } from "./environments/runtime"; | |
| 4 | 5 | |
| 5 | 6 | export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 6 | 7 | return { |
| ... | ... | @@ -54,8 +55,8 @@ export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi |
| 54 | 55 | return undefined; |
| 55 | 56 | } |
| 56 | 57 | |
| 57 | const entry = readWsRpcClientEntryForEnvironment(environmentId); | |
| 58 | return entry ? createEnvironmentApi(entry.client) : undefined; | |
| 58 | const connection = readEnvironmentConnection(environmentId); | |
| 59 | return connection ? createEnvironmentApi(connection.client) : undefined; | |
| 59 | 60 | } |
| 60 | 61 | |
| 61 | 62 | export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi { |
apps/web/src/environmentBootstrap.ts deleted-65| ... | ... | @@ -1,65 +0,0 @@ |
| 1 | import { | |
| 2 | createKnownEnvironmentFromWsUrl, | |
| 3 | getKnownEnvironmentBaseUrl, | |
| 4 | type KnownEnvironment, | |
| 5 | } from "@t3tools/client-runtime"; | |
| 6 | import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | function createKnownEnvironmentFromDesktopBootstrap( | |
| 9 | bootstrap: DesktopEnvironmentBootstrap | null | undefined, | |
| 10 | ): KnownEnvironment | null { | |
| 11 | if (!bootstrap?.wsUrl) { | |
| 12 | return null; | |
| 13 | } | |
| 14 | ||
| 15 | return createKnownEnvironmentFromWsUrl({ | |
| 16 | id: `desktop:${bootstrap.label}`, | |
| 17 | label: bootstrap.label, | |
| 18 | source: "desktop-managed", | |
| 19 | wsUrl: bootstrap.wsUrl, | |
| 20 | }); | |
| 21 | } | |
| 22 | ||
| 23 | export function getPrimaryKnownEnvironment(): KnownEnvironment | null { | |
| 24 | const desktopEnvironment = createKnownEnvironmentFromDesktopBootstrap( | |
| 25 | window.desktopBridge?.getLocalEnvironmentBootstrap(), | |
| 26 | ); | |
| 27 | if (desktopEnvironment) { | |
| 28 | return desktopEnvironment; | |
| 29 | } | |
| 30 | ||
| 31 | const legacyDesktopWsUrl = window.desktopBridge?.getWsUrl(); | |
| 32 | if (typeof legacyDesktopWsUrl === "string" && legacyDesktopWsUrl.length > 0) { | |
| 33 | return createKnownEnvironmentFromWsUrl({ | |
| 34 | id: "desktop-legacy", | |
| 35 | label: "Local environment", | |
| 36 | source: "desktop-managed", | |
| 37 | wsUrl: legacyDesktopWsUrl, | |
| 38 | }); | |
| 39 | } | |
| 40 | ||
| 41 | const configuredWsUrl = import.meta.env.VITE_WS_URL; | |
| 42 | if (typeof configuredWsUrl === "string" && configuredWsUrl.length > 0) { | |
| 43 | return createKnownEnvironmentFromWsUrl({ | |
| 44 | id: "configured-primary", | |
| 45 | label: "Primary environment", | |
| 46 | source: "configured", | |
| 47 | wsUrl: configuredWsUrl, | |
| 48 | }); | |
| 49 | } | |
| 50 | ||
| 51 | return createKnownEnvironmentFromWsUrl({ | |
| 52 | id: "window-origin", | |
| 53 | label: "Primary environment", | |
| 54 | source: "window-origin", | |
| 55 | wsUrl: window.location.origin, | |
| 56 | }); | |
| 57 | } | |
| 58 | ||
| 59 | export function resolvePrimaryEnvironmentBootstrapUrl(): string { | |
| 60 | const baseUrl = getKnownEnvironmentBaseUrl(getPrimaryKnownEnvironment()); | |
| 61 | if (!baseUrl) { | |
| 62 | throw new Error("Unable to resolve a known environment bootstrap URL."); | |
| 63 | } | |
| 64 | return baseUrl; | |
| 65 | } |
apps/web/src/environmentGrouping.test.ts created+453| ... | ... | @@ -0,0 +1,453 @@ |
| 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { scopeProjectRef } from "@t3tools/client-runtime"; | |
| 3 | import { describe, expect, it } from "vitest"; | |
| 4 | ||
| 5 | import { | |
| 6 | selectProjectsAcrossEnvironments, | |
| 7 | selectSidebarThreadsAcrossEnvironments, | |
| 8 | selectSidebarThreadsForProjectRef, | |
| 9 | selectSidebarThreadsForProjectRefs, | |
| 10 | type AppState, | |
| 11 | type EnvironmentState, | |
| 12 | } from "./store"; | |
| 13 | import { deriveLogicalProjectKey } from "./logicalProject"; | |
| 14 | import type { Project, SidebarThreadSummary } from "./types"; | |
| 15 | import { DEFAULT_INTERACTION_MODE } from "./types"; | |
| 16 | ||
| 17 | // ── Fixture Identifiers ────────────────────────────────────────────── | |
| 18 | ||
| 19 | const primaryEnvId = EnvironmentId.makeUnsafe("env-primary"); | |
| 20 | const remoteEnvId = EnvironmentId.makeUnsafe("env-remote"); | |
| 21 | ||
| 22 | const sharedProjectPrimaryId = ProjectId.makeUnsafe("shared-proj-primary"); | |
| 23 | const sharedProjectRemoteId = ProjectId.makeUnsafe("shared-proj-remote"); | |
| 24 | const localOnlyProjectId = ProjectId.makeUnsafe("local-only-proj"); | |
| 25 | const remoteOnlyProjectId = ProjectId.makeUnsafe("remote-only-proj"); | |
| 26 | ||
| 27 | const threadP1 = ThreadId.makeUnsafe("thread-shared-primary-1"); | |
| 28 | const threadP2 = ThreadId.makeUnsafe("thread-shared-primary-2"); | |
| 29 | const threadR1 = ThreadId.makeUnsafe("thread-shared-remote-1"); | |
| 30 | const threadL1 = ThreadId.makeUnsafe("thread-local-only-1"); | |
| 31 | const threadRO1 = ThreadId.makeUnsafe("thread-remote-only-1"); | |
| 32 | ||
| 33 | const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo"; | |
| 34 | ||
| 35 | // ── Factory Helpers ────────────────────────────────────────────────── | |
| 36 | ||
| 37 | function makeProject( | |
| 38 | overrides: Partial<Project> & Pick<Project, "id" | "environmentId" | "name">, | |
| 39 | ): Project { | |
| 40 | return { | |
| 41 | cwd: `/tmp/${overrides.name}`, | |
| 42 | defaultModelSelection: { provider: "codex" as const, model: "gpt-5-codex" }, | |
| 43 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 44 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 45 | scripts: [], | |
| 46 | ...overrides, | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | function makeSidebarThreadSummary( | |
| 51 | overrides: Partial<SidebarThreadSummary> & | |
| 52 | Pick<SidebarThreadSummary, "id" | "environmentId" | "projectId" | "title">, | |
| 53 | ): SidebarThreadSummary { | |
| 54 | return { | |
| 55 | interactionMode: DEFAULT_INTERACTION_MODE, | |
| 56 | session: null, | |
| 57 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 58 | archivedAt: null, | |
| 59 | updatedAt: "2026-01-01T00:00:00.000Z", | |
| 60 | latestTurn: null, | |
| 61 | branch: null, | |
| 62 | worktreePath: null, | |
| 63 | latestUserMessageAt: null, | |
| 64 | hasPendingApprovals: false, | |
| 65 | hasPendingUserInput: false, | |
| 66 | hasActionableProposedPlan: false, | |
| 67 | ...overrides, | |
| 68 | }; | |
| 69 | } | |
| 70 | ||
| 71 | function makeEmptyEnvironmentState(): EnvironmentState { | |
| 72 | return { | |
| 73 | projectIds: [], | |
| 74 | projectById: {}, | |
| 75 | threadIds: [], | |
| 76 | threadIdsByProjectId: {}, | |
| 77 | threadShellById: {}, | |
| 78 | threadSessionById: {}, | |
| 79 | threadTurnStateById: {}, | |
| 80 | messageIdsByThreadId: {}, | |
| 81 | messageByThreadId: {}, | |
| 82 | activityIdsByThreadId: {}, | |
| 83 | activityByThreadId: {}, | |
| 84 | proposedPlanIdsByThreadId: {}, | |
| 85 | proposedPlanByThreadId: {}, | |
| 86 | turnDiffIdsByThreadId: {}, | |
| 87 | turnDiffSummaryByThreadId: {}, | |
| 88 | sidebarThreadSummaryById: {}, | |
| 89 | bootstrapComplete: true, | |
| 90 | }; | |
| 91 | } | |
| 92 | ||
| 93 | // ── Fixture: Two environments, shared + local-only + remote-only projects ── | |
| 94 | ||
| 95 | function makeFixtureState(): AppState { | |
| 96 | // Shared project: same repo in both envs | |
| 97 | const sharedProjectPrimary = makeProject({ | |
| 98 | id: sharedProjectPrimaryId, | |
| 99 | environmentId: primaryEnvId, | |
| 100 | name: "shared-repo", | |
| 101 | repositoryIdentity: { | |
| 102 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | |
| 103 | locator: { | |
| 104 | source: "git-remote", | |
| 105 | remoteName: "origin", | |
| 106 | remoteUrl: "https://github.com/example/shared-repo.git", | |
| 107 | }, | |
| 108 | }, | |
| 109 | }); | |
| 110 | const sharedProjectRemote = makeProject({ | |
| 111 | id: sharedProjectRemoteId, | |
| 112 | environmentId: remoteEnvId, | |
| 113 | name: "shared-repo", | |
| 114 | repositoryIdentity: { | |
| 115 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | |
| 116 | locator: { | |
| 117 | source: "git-remote", | |
| 118 | remoteName: "origin", | |
| 119 | remoteUrl: "https://github.com/example/shared-repo.git", | |
| 120 | }, | |
| 121 | }, | |
| 122 | }); | |
| 123 | // Local-only project | |
| 124 | const localOnlyProject = makeProject({ | |
| 125 | id: localOnlyProjectId, | |
| 126 | environmentId: primaryEnvId, | |
| 127 | name: "local-only", | |
| 128 | }); | |
| 129 | // Remote-only project | |
| 130 | const remoteOnlyProject = makeProject({ | |
| 131 | id: remoteOnlyProjectId, | |
| 132 | environmentId: remoteEnvId, | |
| 133 | name: "remote-only", | |
| 134 | }); | |
| 135 | ||
| 136 | // Threads | |
| 137 | const summaryP1 = makeSidebarThreadSummary({ | |
| 138 | id: threadP1, | |
| 139 | environmentId: primaryEnvId, | |
| 140 | projectId: sharedProjectPrimaryId, | |
| 141 | title: "Shared primary thread 1", | |
| 142 | }); | |
| 143 | const summaryP2 = makeSidebarThreadSummary({ | |
| 144 | id: threadP2, | |
| 145 | environmentId: primaryEnvId, | |
| 146 | projectId: sharedProjectPrimaryId, | |
| 147 | title: "Shared primary thread 2", | |
| 148 | }); | |
| 149 | const summaryR1 = makeSidebarThreadSummary({ | |
| 150 | id: threadR1, | |
| 151 | environmentId: remoteEnvId, | |
| 152 | projectId: sharedProjectRemoteId, | |
| 153 | title: "Shared remote thread 1", | |
| 154 | }); | |
| 155 | const summaryL1 = makeSidebarThreadSummary({ | |
| 156 | id: threadL1, | |
| 157 | environmentId: primaryEnvId, | |
| 158 | projectId: localOnlyProjectId, | |
| 159 | title: "Local only thread 1", | |
| 160 | }); | |
| 161 | const summaryRO1 = makeSidebarThreadSummary({ | |
| 162 | id: threadRO1, | |
| 163 | environmentId: remoteEnvId, | |
| 164 | projectId: remoteOnlyProjectId, | |
| 165 | title: "Remote only thread 1", | |
| 166 | }); | |
| 167 | ||
| 168 | const primaryEnvState: EnvironmentState = { | |
| 169 | ...makeEmptyEnvironmentState(), | |
| 170 | projectIds: [sharedProjectPrimaryId, localOnlyProjectId], | |
| 171 | projectById: { | |
| 172 | [sharedProjectPrimaryId]: sharedProjectPrimary, | |
| 173 | [localOnlyProjectId]: localOnlyProject, | |
| 174 | }, | |
| 175 | threadIds: [threadP1, threadP2, threadL1], | |
| 176 | threadIdsByProjectId: { | |
| 177 | [sharedProjectPrimaryId]: [threadP1, threadP2], | |
| 178 | [localOnlyProjectId]: [threadL1], | |
| 179 | }, | |
| 180 | sidebarThreadSummaryById: { | |
| 181 | [threadP1]: summaryP1, | |
| 182 | [threadP2]: summaryP2, | |
| 183 | [threadL1]: summaryL1, | |
| 184 | }, | |
| 185 | }; | |
| 186 | ||
| 187 | const remoteEnvState: EnvironmentState = { | |
| 188 | ...makeEmptyEnvironmentState(), | |
| 189 | projectIds: [sharedProjectRemoteId, remoteOnlyProjectId], | |
| 190 | projectById: { | |
| 191 | [sharedProjectRemoteId]: sharedProjectRemote, | |
| 192 | [remoteOnlyProjectId]: remoteOnlyProject, | |
| 193 | }, | |
| 194 | threadIds: [threadR1, threadRO1], | |
| 195 | threadIdsByProjectId: { | |
| 196 | [sharedProjectRemoteId]: [threadR1], | |
| 197 | [remoteOnlyProjectId]: [threadRO1], | |
| 198 | }, | |
| 199 | sidebarThreadSummaryById: { | |
| 200 | [threadR1]: summaryR1, | |
| 201 | [threadRO1]: summaryRO1, | |
| 202 | }, | |
| 203 | }; | |
| 204 | ||
| 205 | return { | |
| 206 | activeEnvironmentId: primaryEnvId, | |
| 207 | environmentStateById: { | |
| 208 | [primaryEnvId]: primaryEnvState, | |
| 209 | [remoteEnvId]: remoteEnvState, | |
| 210 | }, | |
| 211 | }; | |
| 212 | } | |
| 213 | ||
| 214 | // ── Tests ──────────────────────────────────────────────────────────── | |
| 215 | ||
| 216 | describe("environment grouping", () => { | |
| 217 | describe("deriveLogicalProjectKey", () => { | |
| 218 | it("uses repositoryIdentity.canonicalKey when present", () => { | |
| 219 | const project = makeProject({ | |
| 220 | id: sharedProjectPrimaryId, | |
| 221 | environmentId: primaryEnvId, | |
| 222 | name: "shared-repo", | |
| 223 | repositoryIdentity: { | |
| 224 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | |
| 225 | locator: { | |
| 226 | source: "git-remote", | |
| 227 | remoteName: "origin", | |
| 228 | remoteUrl: "https://github.com/example/shared-repo.git", | |
| 229 | }, | |
| 230 | }, | |
| 231 | }); | |
| 232 | expect(deriveLogicalProjectKey(project)).toBe(SHARED_REPO_CANONICAL_KEY); | |
| 233 | }); | |
| 234 | ||
| 235 | it("falls back to scoped project key when no repositoryIdentity", () => { | |
| 236 | const project = makeProject({ | |
| 237 | id: localOnlyProjectId, | |
| 238 | environmentId: primaryEnvId, | |
| 239 | name: "local-only", | |
| 240 | }); | |
| 241 | const key = deriveLogicalProjectKey(project); | |
| 242 | expect(key).toContain(primaryEnvId); | |
| 243 | expect(key).toContain(localOnlyProjectId); | |
| 244 | }); | |
| 245 | ||
| 246 | it("groups projects from different environments that share the same canonical key", () => { | |
| 247 | const primary = makeProject({ | |
| 248 | id: sharedProjectPrimaryId, | |
| 249 | environmentId: primaryEnvId, | |
| 250 | name: "shared-repo", | |
| 251 | repositoryIdentity: { | |
| 252 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | |
| 253 | locator: { | |
| 254 | source: "git-remote", | |
| 255 | remoteName: "origin", | |
| 256 | remoteUrl: "https://github.com/example/shared-repo.git", | |
| 257 | }, | |
| 258 | }, | |
| 259 | }); | |
| 260 | const remote = makeProject({ | |
| 261 | id: sharedProjectRemoteId, | |
| 262 | environmentId: remoteEnvId, | |
| 263 | name: "shared-repo", | |
| 264 | repositoryIdentity: { | |
| 265 | canonicalKey: SHARED_REPO_CANONICAL_KEY, | |
| 266 | locator: { | |
| 267 | source: "git-remote", | |
| 268 | remoteName: "origin", | |
| 269 | remoteUrl: "https://github.com/example/shared-repo.git", | |
| 270 | }, | |
| 271 | }, | |
| 272 | }); | |
| 273 | expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); | |
| 274 | }); | |
| 275 | ||
| 276 | it("does NOT group projects without shared canonical key", () => { | |
| 277 | const local = makeProject({ | |
| 278 | id: localOnlyProjectId, | |
| 279 | environmentId: primaryEnvId, | |
| 280 | name: "local-only", | |
| 281 | }); | |
| 282 | const remote = makeProject({ | |
| 283 | id: remoteOnlyProjectId, | |
| 284 | environmentId: remoteEnvId, | |
| 285 | name: "remote-only", | |
| 286 | }); | |
| 287 | expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote)); | |
| 288 | }); | |
| 289 | }); | |
| 290 | ||
| 291 | describe("selectProjectsAcrossEnvironments", () => { | |
| 292 | it("returns all projects from all environments", () => { | |
| 293 | const state = makeFixtureState(); | |
| 294 | const projects = selectProjectsAcrossEnvironments(state); | |
| 295 | expect(projects).toHaveLength(4); | |
| 296 | const names = projects.map((p) => p.name).toSorted(); | |
| 297 | expect(names).toEqual(["local-only", "remote-only", "shared-repo", "shared-repo"]); | |
| 298 | }); | |
| 299 | }); | |
| 300 | ||
| 301 | describe("selectSidebarThreadsAcrossEnvironments", () => { | |
| 302 | it("returns all sidebar thread summaries from all environments", () => { | |
| 303 | const state = makeFixtureState(); | |
| 304 | const threads = selectSidebarThreadsAcrossEnvironments(state); | |
| 305 | expect(threads).toHaveLength(5); | |
| 306 | const ids = new Set(threads.map((t) => t.id)); | |
| 307 | expect(ids).toContain(threadP1); | |
| 308 | expect(ids).toContain(threadP2); | |
| 309 | expect(ids).toContain(threadR1); | |
| 310 | expect(ids).toContain(threadL1); | |
| 311 | expect(ids).toContain(threadRO1); | |
| 312 | }); | |
| 313 | }); | |
| 314 | ||
| 315 | describe("selectSidebarThreadsForProjectRef", () => { | |
| 316 | it("returns threads for a single project ref", () => { | |
| 317 | const state = makeFixtureState(); | |
| 318 | const ref = scopeProjectRef(primaryEnvId, sharedProjectPrimaryId); | |
| 319 | const threads = selectSidebarThreadsForProjectRef(state, ref); | |
| 320 | expect(threads).toHaveLength(2); | |
| 321 | expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); | |
| 322 | }); | |
| 323 | ||
| 324 | it("returns empty array for null ref", () => { | |
| 325 | const state = makeFixtureState(); | |
| 326 | expect(selectSidebarThreadsForProjectRef(state, null)).toEqual([]); | |
| 327 | }); | |
| 328 | ||
| 329 | it("returns empty array for nonexistent environment", () => { | |
| 330 | const state = makeFixtureState(); | |
| 331 | const ref = scopeProjectRef(EnvironmentId.makeUnsafe("nonexistent"), sharedProjectPrimaryId); | |
| 332 | expect(selectSidebarThreadsForProjectRef(state, ref)).toEqual([]); | |
| 333 | }); | |
| 334 | }); | |
| 335 | ||
| 336 | describe("selectSidebarThreadsForProjectRefs", () => { | |
| 337 | it("returns empty for empty refs", () => { | |
| 338 | const state = makeFixtureState(); | |
| 339 | expect(selectSidebarThreadsForProjectRefs(state, [])).toEqual([]); | |
| 340 | }); | |
| 341 | ||
| 342 | it("returns threads for a single ref", () => { | |
| 343 | const state = makeFixtureState(); | |
| 344 | const refs = [scopeProjectRef(primaryEnvId, sharedProjectPrimaryId)]; | |
| 345 | const threads = selectSidebarThreadsForProjectRefs(state, refs); | |
| 346 | expect(threads).toHaveLength(2); | |
| 347 | expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); | |
| 348 | }); | |
| 349 | ||
| 350 | it("returns combined threads from multiple refs across environments", () => { | |
| 351 | const state = makeFixtureState(); | |
| 352 | const refs = [ | |
| 353 | scopeProjectRef(primaryEnvId, sharedProjectPrimaryId), | |
| 354 | scopeProjectRef(remoteEnvId, sharedProjectRemoteId), | |
| 355 | ]; | |
| 356 | const threads = selectSidebarThreadsForProjectRefs(state, refs); | |
| 357 | expect(threads).toHaveLength(3); | |
| 358 | const ids = new Set(threads.map((t) => t.id)); | |
| 359 | expect(ids).toContain(threadP1); | |
| 360 | expect(ids).toContain(threadP2); | |
| 361 | expect(ids).toContain(threadR1); | |
| 362 | }); | |
| 363 | ||
| 364 | it("returns threads from remote-only project", () => { | |
| 365 | const state = makeFixtureState(); | |
| 366 | const refs = [scopeProjectRef(remoteEnvId, remoteOnlyProjectId)]; | |
| 367 | const threads = selectSidebarThreadsForProjectRefs(state, refs); | |
| 368 | expect(threads).toHaveLength(1); | |
| 369 | expect(threads[0]?.id).toBe(threadRO1); | |
| 370 | }); | |
| 371 | ||
| 372 | it("returns threads from local-only project", () => { | |
| 373 | const state = makeFixtureState(); | |
| 374 | const refs = [scopeProjectRef(primaryEnvId, localOnlyProjectId)]; | |
| 375 | const threads = selectSidebarThreadsForProjectRefs(state, refs); | |
| 376 | expect(threads).toHaveLength(1); | |
| 377 | expect(threads[0]?.id).toBe(threadL1); | |
| 378 | }); | |
| 379 | ||
| 380 | it("handles refs with nonexistent environment gracefully", () => { | |
| 381 | const state = makeFixtureState(); | |
| 382 | const refs = [ | |
| 383 | scopeProjectRef(primaryEnvId, sharedProjectPrimaryId), | |
| 384 | scopeProjectRef(EnvironmentId.makeUnsafe("nonexistent"), ProjectId.makeUnsafe("nope")), | |
| 385 | ]; | |
| 386 | const threads = selectSidebarThreadsForProjectRefs(state, refs); | |
| 387 | // Only returns threads from the valid ref | |
| 388 | expect(threads).toHaveLength(2); | |
| 389 | expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); | |
| 390 | }); | |
| 391 | }); | |
| 392 | ||
| 393 | describe("logical project grouping for sidebar", () => { | |
| 394 | it("computes correct logical key for grouped projects and aggregates threads", () => { | |
| 395 | const state = makeFixtureState(); | |
| 396 | const allProjects = selectProjectsAcrossEnvironments(state); | |
| 397 | ||
| 398 | // Group by logical key | |
| 399 | const groups = new Map<string, Project[]>(); | |
| 400 | for (const project of allProjects) { | |
| 401 | const key = deriveLogicalProjectKey(project); | |
| 402 | const existing = groups.get(key) ?? []; | |
| 403 | existing.push(project); | |
| 404 | groups.set(key, existing); | |
| 405 | } | |
| 406 | ||
| 407 | // Shared project should be grouped | |
| 408 | const sharedGroup = groups.get(SHARED_REPO_CANONICAL_KEY); | |
| 409 | expect(sharedGroup).toBeDefined(); | |
| 410 | expect(sharedGroup).toHaveLength(2); | |
| 411 | expect(sharedGroup!.map((p) => p.environmentId).toSorted()).toEqual( | |
| 412 | [primaryEnvId, remoteEnvId].toSorted(), | |
| 413 | ); | |
| 414 | ||
| 415 | // Build member refs for the grouped project and fetch threads | |
| 416 | const memberRefs = sharedGroup!.map((p) => scopeProjectRef(p.environmentId, p.id)); | |
| 417 | const threads = selectSidebarThreadsForProjectRefs(state, memberRefs); | |
| 418 | expect(threads).toHaveLength(3); | |
| 419 | const threadIds = threads.map((t) => t.id); | |
| 420 | expect(threadIds).toContain(threadP1); | |
| 421 | expect(threadIds).toContain(threadP2); | |
| 422 | expect(threadIds).toContain(threadR1); | |
| 423 | }); | |
| 424 | ||
| 425 | it("local-only and remote-only projects remain ungrouped", () => { | |
| 426 | const state = makeFixtureState(); | |
| 427 | const allProjects = selectProjectsAcrossEnvironments(state); | |
| 428 | ||
| 429 | const groups = new Map<string, Project[]>(); | |
| 430 | for (const project of allProjects) { | |
| 431 | const key = deriveLogicalProjectKey(project); | |
| 432 | const existing = groups.get(key) ?? []; | |
| 433 | existing.push(project); | |
| 434 | groups.set(key, existing); | |
| 435 | } | |
| 436 | ||
| 437 | // Should have 3 groups total: shared, local-only, remote-only | |
| 438 | expect(groups.size).toBe(3); | |
| 439 | ||
| 440 | // Local-only group | |
| 441 | const localKey = deriveLogicalProjectKey( | |
| 442 | allProjects.find((p) => p.id === localOnlyProjectId)!, | |
| 443 | ); | |
| 444 | expect(groups.get(localKey)).toHaveLength(1); | |
| 445 | ||
| 446 | // Remote-only group | |
| 447 | const remoteKey = deriveLogicalProjectKey( | |
| 448 | allProjects.find((p) => p.id === remoteOnlyProjectId)!, | |
| 449 | ); | |
| 450 | expect(groups.get(remoteKey)).toHaveLength(1); | |
| 451 | }); | |
| 452 | }); | |
| 453 | }); |
apps/web/src/environments/primary/auth.ts created+361| ... | ... | @@ -0,0 +1,361 @@ |
| 1 | import type { | |
| 2 | AuthBootstrapInput, | |
| 3 | AuthBootstrapResult, | |
| 4 | AuthClientMetadata, | |
| 5 | AuthCreatePairingCredentialInput, | |
| 6 | AuthPairingCredentialResult, | |
| 7 | AuthRevokeClientSessionInput, | |
| 8 | AuthRevokePairingLinkInput, | |
| 9 | AuthSessionId, | |
| 10 | AuthSessionState, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | ||
| 13 | import { | |
| 14 | getPairingTokenFromUrl, | |
| 15 | stripPairingTokenFromUrl as stripPairingTokenUrl, | |
| 16 | } from "../../pairingUrl"; | |
| 17 | ||
| 18 | import { resolvePrimaryEnvironmentHttpUrl } from "./target"; | |
| 19 | ||
| 20 | export interface ServerPairingLinkRecord { | |
| 21 | readonly id: string; | |
| 22 | readonly credential: string; | |
| 23 | readonly role: "owner" | "client"; | |
| 24 | readonly subject: string; | |
| 25 | readonly label?: string; | |
| 26 | readonly createdAt: string; | |
| 27 | readonly expiresAt: string; | |
| 28 | } | |
| 29 | ||
| 30 | export interface ServerClientSessionRecord { | |
| 31 | readonly sessionId: AuthSessionId; | |
| 32 | readonly subject: string; | |
| 33 | readonly role: "owner" | "client"; | |
| 34 | readonly method: "browser-session-cookie" | "bearer-session-token"; | |
| 35 | readonly client: AuthClientMetadata; | |
| 36 | readonly issuedAt: string; | |
| 37 | readonly expiresAt: string; | |
| 38 | readonly lastConnectedAt: string | null; | |
| 39 | readonly connected: boolean; | |
| 40 | readonly current: boolean; | |
| 41 | } | |
| 42 | ||
| 43 | type ServerAuthGateState = | |
| 44 | | { status: "authenticated" } | |
| 45 | | { | |
| 46 | status: "requires-auth"; | |
| 47 | auth: AuthSessionState["auth"]; | |
| 48 | errorMessage?: string; | |
| 49 | }; | |
| 50 | ||
| 51 | let bootstrapPromise: Promise<ServerAuthGateState> | null = null; | |
| 52 | const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; | |
| 53 | const AUTH_SESSION_ESTABLISH_STEP_MS = 100; | |
| 54 | ||
| 55 | export function peekPairingTokenFromUrl(): string | null { | |
| 56 | return getPairingTokenFromUrl(new URL(window.location.href)); | |
| 57 | } | |
| 58 | ||
| 59 | export function stripPairingTokenFromUrl() { | |
| 60 | const url = new URL(window.location.href); | |
| 61 | const next = stripPairingTokenUrl(url); | |
| 62 | if (next.toString() === url.toString()) { | |
| 63 | return; | |
| 64 | } | |
| 65 | window.history.replaceState({}, document.title, next.toString()); | |
| 66 | } | |
| 67 | ||
| 68 | export function takePairingTokenFromUrl(): string | null { | |
| 69 | const token = peekPairingTokenFromUrl(); | |
| 70 | if (!token) { | |
| 71 | return null; | |
| 72 | } | |
| 73 | stripPairingTokenFromUrl(); | |
| 74 | return token; | |
| 75 | } | |
| 76 | ||
| 77 | function getDesktopBootstrapCredential(): string | null { | |
| 78 | const bootstrap = window.desktopBridge?.getLocalEnvironmentBootstrap(); | |
| 79 | return typeof bootstrap?.bootstrapToken === "string" && bootstrap.bootstrapToken.length > 0 | |
| 80 | ? bootstrap.bootstrapToken | |
| 81 | : null; | |
| 82 | } | |
| 83 | ||
| 84 | export async function fetchSessionState(): Promise<AuthSessionState> { | |
| 85 | return retryTransientBootstrap(async () => { | |
| 86 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/session"), { | |
| 87 | credentials: "include", | |
| 88 | }); | |
| 89 | if (!response.ok) { | |
| 90 | throw new BootstrapHttpError( | |
| 91 | `Failed to load server auth session state (${response.status}).`, | |
| 92 | response.status, | |
| 93 | ); | |
| 94 | } | |
| 95 | return (await response.json()) as AuthSessionState; | |
| 96 | }); | |
| 97 | } | |
| 98 | ||
| 99 | async function readErrorMessage(response: Response, fallbackMessage: string): Promise<string> { | |
| 100 | const text = await response.text(); | |
| 101 | return text || fallbackMessage; | |
| 102 | } | |
| 103 | ||
| 104 | async function exchangeBootstrapCredential(credential: string): Promise<AuthBootstrapResult> { | |
| 105 | return retryTransientBootstrap(async () => { | |
| 106 | const payload: AuthBootstrapInput = { credential }; | |
| 107 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/bootstrap"), { | |
| 108 | body: JSON.stringify(payload), | |
| 109 | credentials: "include", | |
| 110 | headers: { | |
| 111 | "content-type": "application/json", | |
| 112 | }, | |
| 113 | method: "POST", | |
| 114 | }); | |
| 115 | ||
| 116 | if (!response.ok) { | |
| 117 | const message = await response.text(); | |
| 118 | throw new BootstrapHttpError( | |
| 119 | message || `Failed to bootstrap auth session (${response.status}).`, | |
| 120 | response.status, | |
| 121 | ); | |
| 122 | } | |
| 123 | ||
| 124 | return (await response.json()) as AuthBootstrapResult; | |
| 125 | }); | |
| 126 | } | |
| 127 | ||
| 128 | async function waitForAuthenticatedSessionAfterBootstrap(): Promise<AuthSessionState> { | |
| 129 | const startedAt = Date.now(); | |
| 130 | ||
| 131 | while (true) { | |
| 132 | const session = await fetchSessionState(); | |
| 133 | if (session.authenticated) { | |
| 134 | return session; | |
| 135 | } | |
| 136 | ||
| 137 | if (Date.now() - startedAt >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { | |
| 138 | throw new Error("Timed out waiting for authenticated session after bootstrap."); | |
| 139 | } | |
| 140 | ||
| 141 | await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS); | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); | |
| 146 | const BOOTSTRAP_RETRY_TIMEOUT_MS = 15_000; | |
| 147 | const BOOTSTRAP_RETRY_STEP_MS = 500; | |
| 148 | ||
| 149 | export class BootstrapHttpError extends Error { | |
| 150 | readonly status: number; | |
| 151 | ||
| 152 | constructor(message: string, status: number) { | |
| 153 | super(message); | |
| 154 | this.name = "BootstrapHttpError"; | |
| 155 | this.status = status; | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | export async function retryTransientBootstrap<T>(operation: () => Promise<T>): Promise<T> { | |
| 160 | const startedAt = Date.now(); | |
| 161 | while (true) { | |
| 162 | try { | |
| 163 | return await operation(); | |
| 164 | } catch (error) { | |
| 165 | if (!isTransientBootstrapError(error)) { | |
| 166 | throw error; | |
| 167 | } | |
| 168 | ||
| 169 | if (Date.now() - startedAt >= BOOTSTRAP_RETRY_TIMEOUT_MS) { | |
| 170 | throw error; | |
| 171 | } | |
| 172 | ||
| 173 | await waitForBootstrapRetry(BOOTSTRAP_RETRY_STEP_MS); | |
| 174 | } | |
| 175 | } | |
| 176 | } | |
| 177 | ||
| 178 | function waitForBootstrapRetry(delayMs: number): Promise<void> { | |
| 179 | return new Promise((resolve) => { | |
| 180 | setTimeout(resolve, delayMs); | |
| 181 | }); | |
| 182 | } | |
| 183 | ||
| 184 | function isTransientBootstrapError(error: unknown): boolean { | |
| 185 | if (error instanceof BootstrapHttpError) { | |
| 186 | return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); | |
| 187 | } | |
| 188 | ||
| 189 | if (error instanceof TypeError) { | |
| 190 | return true; | |
| 191 | } | |
| 192 | ||
| 193 | return error instanceof DOMException && error.name === "AbortError"; | |
| 194 | } | |
| 195 | ||
| 196 | async function bootstrapServerAuth(): Promise<ServerAuthGateState> { | |
| 197 | const bootstrapCredential = getDesktopBootstrapCredential(); | |
| 198 | const currentSession = await fetchSessionState(); | |
| 199 | if (currentSession.authenticated) { | |
| 200 | return { status: "authenticated" }; | |
| 201 | } | |
| 202 | ||
| 203 | if (!bootstrapCredential) { | |
| 204 | return { | |
| 205 | status: "requires-auth", | |
| 206 | auth: currentSession.auth, | |
| 207 | }; | |
| 208 | } | |
| 209 | ||
| 210 | try { | |
| 211 | await exchangeBootstrapCredential(bootstrapCredential); | |
| 212 | await waitForAuthenticatedSessionAfterBootstrap(); | |
| 213 | return { status: "authenticated" }; | |
| 214 | } catch (error) { | |
| 215 | return { | |
| 216 | status: "requires-auth", | |
| 217 | auth: currentSession.auth, | |
| 218 | errorMessage: error instanceof Error ? error.message : "Authentication failed.", | |
| 219 | }; | |
| 220 | } | |
| 221 | } | |
| 222 | ||
| 223 | export async function submitServerAuthCredential(credential: string): Promise<void> { | |
| 224 | const trimmedCredential = credential.trim(); | |
| 225 | if (!trimmedCredential) { | |
| 226 | throw new Error("Enter a pairing token to continue."); | |
| 227 | } | |
| 228 | ||
| 229 | await exchangeBootstrapCredential(trimmedCredential); | |
| 230 | bootstrapPromise = null; | |
| 231 | stripPairingTokenFromUrl(); | |
| 232 | } | |
| 233 | ||
| 234 | export async function createServerPairingCredential( | |
| 235 | label?: string, | |
| 236 | ): Promise<AuthPairingCredentialResult> { | |
| 237 | const trimmedLabel = label?.trim(); | |
| 238 | const payload: AuthCreatePairingCredentialInput = trimmedLabel ? { label: trimmedLabel } : {}; | |
| 239 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-token"), { | |
| 240 | body: JSON.stringify(payload), | |
| 241 | credentials: "include", | |
| 242 | headers: { | |
| 243 | "content-type": "application/json", | |
| 244 | }, | |
| 245 | method: "POST", | |
| 246 | }); | |
| 247 | ||
| 248 | if (!response.ok) { | |
| 249 | throw new Error( | |
| 250 | await readErrorMessage(response, `Failed to create pairing credential (${response.status}).`), | |
| 251 | ); | |
| 252 | } | |
| 253 | ||
| 254 | return (await response.json()) as AuthPairingCredentialResult; | |
| 255 | } | |
| 256 | ||
| 257 | export async function listServerPairingLinks(): Promise<ReadonlyArray<ServerPairingLinkRecord>> { | |
| 258 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-links"), { | |
| 259 | credentials: "include", | |
| 260 | }); | |
| 261 | ||
| 262 | if (!response.ok) { | |
| 263 | throw new Error( | |
| 264 | await readErrorMessage(response, `Failed to load pairing links (${response.status}).`), | |
| 265 | ); | |
| 266 | } | |
| 267 | ||
| 268 | return (await response.json()) as ReadonlyArray<ServerPairingLinkRecord>; | |
| 269 | } | |
| 270 | ||
| 271 | export async function revokeServerPairingLink(id: string): Promise<void> { | |
| 272 | const payload: AuthRevokePairingLinkInput = { id }; | |
| 273 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/pairing-links/revoke"), { | |
| 274 | body: JSON.stringify(payload), | |
| 275 | credentials: "include", | |
| 276 | headers: { | |
| 277 | "content-type": "application/json", | |
| 278 | }, | |
| 279 | method: "POST", | |
| 280 | }); | |
| 281 | ||
| 282 | if (!response.ok) { | |
| 283 | throw new Error( | |
| 284 | await readErrorMessage(response, `Failed to revoke pairing link (${response.status}).`), | |
| 285 | ); | |
| 286 | } | |
| 287 | } | |
| 288 | ||
| 289 | export async function listServerClientSessions(): Promise< | |
| 290 | ReadonlyArray<ServerClientSessionRecord> | |
| 291 | > { | |
| 292 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/clients"), { | |
| 293 | credentials: "include", | |
| 294 | }); | |
| 295 | ||
| 296 | if (!response.ok) { | |
| 297 | throw new Error( | |
| 298 | await readErrorMessage(response, `Failed to load paired clients (${response.status}).`), | |
| 299 | ); | |
| 300 | } | |
| 301 | ||
| 302 | return (await response.json()) as ReadonlyArray<ServerClientSessionRecord>; | |
| 303 | } | |
| 304 | ||
| 305 | export async function revokeServerClientSession(sessionId: AuthSessionId): Promise<void> { | |
| 306 | const payload: AuthRevokeClientSessionInput = { sessionId }; | |
| 307 | const response = await fetch(resolvePrimaryEnvironmentHttpUrl("/api/auth/clients/revoke"), { | |
| 308 | body: JSON.stringify(payload), | |
| 309 | credentials: "include", | |
| 310 | headers: { | |
| 311 | "content-type": "application/json", | |
| 312 | }, | |
| 313 | method: "POST", | |
| 314 | }); | |
| 315 | ||
| 316 | if (!response.ok) { | |
| 317 | throw new Error( | |
| 318 | await readErrorMessage(response, `Failed to revoke client session (${response.status}).`), | |
| 319 | ); | |
| 320 | } | |
| 321 | } | |
| 322 | ||
| 323 | export async function revokeOtherServerClientSessions(): Promise<number> { | |
| 324 | const response = await fetch( | |
| 325 | resolvePrimaryEnvironmentHttpUrl("/api/auth/clients/revoke-others"), | |
| 326 | { | |
| 327 | credentials: "include", | |
| 328 | method: "POST", | |
| 329 | }, | |
| 330 | ); | |
| 331 | ||
| 332 | if (!response.ok) { | |
| 333 | throw new Error( | |
| 334 | await readErrorMessage( | |
| 335 | response, | |
| 336 | `Failed to revoke other client sessions (${response.status}).`, | |
| 337 | ), | |
| 338 | ); | |
| 339 | } | |
| 340 | ||
| 341 | const result = (await response.json()) as { revokedCount?: number }; | |
| 342 | return result.revokedCount ?? 0; | |
| 343 | } | |
| 344 | ||
| 345 | export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> { | |
| 346 | if (bootstrapPromise) { | |
| 347 | return bootstrapPromise; | |
| 348 | } | |
| 349 | ||
| 350 | const nextPromise = bootstrapServerAuth(); | |
| 351 | bootstrapPromise = nextPromise; | |
| 352 | return nextPromise.finally(() => { | |
| 353 | if (bootstrapPromise === nextPromise) { | |
| 354 | bootstrapPromise = null; | |
| 355 | } | |
| 356 | }); | |
| 357 | } | |
| 358 | ||
| 359 | export function __resetServerAuthBootstrapForTests() { | |
| 360 | bootstrapPromise = null; | |
| 361 | } |
apps/web/src/environments/primary/bootstrap.test.ts created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import { | |
| 5 | getPrimaryKnownEnvironment, | |
| 6 | resolveInitialPrimaryEnvironmentDescriptor, | |
| 7 | resetPrimaryEnvironmentDescriptorForTests, | |
| 8 | writePrimaryEnvironmentDescriptor, | |
| 9 | } from "."; | |
| 10 | ||
| 11 | function jsonResponse(body: unknown, init?: ResponseInit) { | |
| 12 | return new Response(JSON.stringify(body), { | |
| 13 | headers: { | |
| 14 | "content-type": "application/json", | |
| 15 | }, | |
| 16 | status: 200, | |
| 17 | ...init, | |
| 18 | }); | |
| 19 | } | |
| 20 | ||
| 21 | const BASE_ENVIRONMENT = { | |
| 22 | environmentId: "environment-local", | |
| 23 | label: "Local environment", | |
| 24 | platform: { | |
| 25 | os: "darwin", | |
| 26 | arch: "arm64", | |
| 27 | }, | |
| 28 | serverVersion: "0.0.0-test", | |
| 29 | capabilities: { | |
| 30 | repositoryIdentity: true, | |
| 31 | }, | |
| 32 | }; | |
| 33 | ||
| 34 | function installTestBrowser(url: string) { | |
| 35 | vi.stubGlobal("window", { | |
| 36 | location: new URL(url), | |
| 37 | history: { | |
| 38 | replaceState: vi.fn(), | |
| 39 | }, | |
| 40 | }); | |
| 41 | } | |
| 42 | ||
| 43 | describe("environmentBootstrap", () => { | |
| 44 | beforeEach(() => { | |
| 45 | vi.restoreAllMocks(); | |
| 46 | vi.useRealTimers(); | |
| 47 | installTestBrowser("http://localhost/"); | |
| 48 | }); | |
| 49 | ||
| 50 | afterEach(() => { | |
| 51 | resetPrimaryEnvironmentDescriptorForTests(); | |
| 52 | vi.unstubAllEnvs(); | |
| 53 | vi.useRealTimers(); | |
| 54 | vi.restoreAllMocks(); | |
| 55 | vi.unstubAllGlobals(); | |
| 56 | }); | |
| 57 | ||
| 58 | it("attaches the bootstrapped environment descriptor to the primary environment", () => { | |
| 59 | vi.stubGlobal("window", { | |
| 60 | location: { | |
| 61 | origin: "http://localhost:3773", | |
| 62 | }, | |
| 63 | desktopBridge: undefined, | |
| 64 | }); | |
| 65 | writePrimaryEnvironmentDescriptor({ | |
| 66 | environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 67 | label: "Bootstrapped environment", | |
| 68 | platform: { | |
| 69 | os: "darwin", | |
| 70 | arch: "arm64", | |
| 71 | }, | |
| 72 | serverVersion: "0.0.0-test", | |
| 73 | capabilities: { | |
| 74 | repositoryIdentity: true, | |
| 75 | }, | |
| 76 | }); | |
| 77 | ||
| 78 | expect(getPrimaryKnownEnvironment()).toEqual({ | |
| 79 | id: "environment-local", | |
| 80 | label: "Bootstrapped environment", | |
| 81 | source: "window-origin", | |
| 82 | environmentId: "environment-local", | |
| 83 | target: { | |
| 84 | httpBaseUrl: "http://localhost:3773/", | |
| 85 | wsBaseUrl: "ws://localhost:3773/", | |
| 86 | }, | |
| 87 | }); | |
| 88 | }); | |
| 89 | ||
| 90 | it("reuses an in-flight descriptor bootstrap request", async () => { | |
| 91 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | |
| 92 | vi.stubGlobal("fetch", fetchMock); | |
| 93 | ||
| 94 | await Promise.all([ | |
| 95 | resolveInitialPrimaryEnvironmentDescriptor(), | |
| 96 | resolveInitialPrimaryEnvironmentDescriptor(), | |
| 97 | ]); | |
| 98 | ||
| 99 | expect(fetchMock).toHaveBeenCalledTimes(1); | |
| 100 | expect(fetchMock).toHaveBeenCalledWith("http://localhost/.well-known/t3/environment"); | |
| 101 | }); | |
| 102 | ||
| 103 | it("uses https descriptor urls when the primary environment uses wss", async () => { | |
| 104 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | |
| 105 | vi.stubGlobal("fetch", fetchMock); | |
| 106 | vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); | |
| 107 | vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); | |
| 108 | ||
| 109 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); | |
| 110 | expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment"); | |
| 111 | }); | |
| 112 | ||
| 113 | it("uses the current origin as the descriptor base for local dev environments", async () => { | |
| 114 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | |
| 115 | vi.stubGlobal("fetch", fetchMock); | |
| 116 | installTestBrowser("http://localhost:5735/"); | |
| 117 | ||
| 118 | await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); | |
| 119 | expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/.well-known/t3/environment"); | |
| 120 | }); | |
| 121 | }); |
apps/web/src/environments/primary/context.ts created+120| ... | ... | @@ -0,0 +1,120 @@ |
| 1 | import { | |
| 2 | attachEnvironmentDescriptor, | |
| 3 | createKnownEnvironment, | |
| 4 | type KnownEnvironment, | |
| 5 | } from "@t3tools/client-runtime"; | |
| 6 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | |
| 7 | import { create } from "zustand"; | |
| 8 | ||
| 9 | import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; | |
| 10 | ||
| 11 | import { readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl } from "./target"; | |
| 12 | ||
| 13 | const SERVER_ENVIRONMENT_DESCRIPTOR_PATH = "/.well-known/t3/environment"; | |
| 14 | ||
| 15 | interface PrimaryEnvironmentBootstrapState { | |
| 16 | readonly descriptor: ExecutionEnvironmentDescriptor | null; | |
| 17 | readonly setDescriptor: (descriptor: ExecutionEnvironmentDescriptor | null) => void; | |
| 18 | readonly reset: () => void; | |
| 19 | } | |
| 20 | ||
| 21 | const usePrimaryEnvironmentBootstrapStore = create<PrimaryEnvironmentBootstrapState>()((set) => ({ | |
| 22 | descriptor: null, | |
| 23 | setDescriptor: (descriptor) => set({ descriptor }), | |
| 24 | reset: () => set({ descriptor: null }), | |
| 25 | })); | |
| 26 | ||
| 27 | let primaryEnvironmentDescriptorPromise: Promise<ExecutionEnvironmentDescriptor> | null = null; | |
| 28 | ||
| 29 | function createPrimaryKnownEnvironment(input: { | |
| 30 | readonly source: KnownEnvironment["source"]; | |
| 31 | readonly target: KnownEnvironment["target"]; | |
| 32 | }): KnownEnvironment | null { | |
| 33 | const descriptor = readPrimaryEnvironmentDescriptor(); | |
| 34 | if (!descriptor) { | |
| 35 | return null; | |
| 36 | } | |
| 37 | ||
| 38 | return attachEnvironmentDescriptor( | |
| 39 | createKnownEnvironment({ | |
| 40 | id: descriptor.environmentId, | |
| 41 | label: descriptor.label, | |
| 42 | source: input.source, | |
| 43 | target: input.target, | |
| 44 | }), | |
| 45 | descriptor, | |
| 46 | ); | |
| 47 | } | |
| 48 | ||
| 49 | async function fetchPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironmentDescriptor> { | |
| 50 | return retryTransientBootstrap(async () => { | |
| 51 | const response = await fetch( | |
| 52 | resolvePrimaryEnvironmentHttpUrl(SERVER_ENVIRONMENT_DESCRIPTOR_PATH), | |
| 53 | ); | |
| 54 | if (!response.ok) { | |
| 55 | throw new BootstrapHttpError( | |
| 56 | `Failed to load server environment descriptor (${response.status}).`, | |
| 57 | response.status, | |
| 58 | ); | |
| 59 | } | |
| 60 | ||
| 61 | const descriptor = (await response.json()) as ExecutionEnvironmentDescriptor; | |
| 62 | writePrimaryEnvironmentDescriptor(descriptor); | |
| 63 | return descriptor; | |
| 64 | }); | |
| 65 | } | |
| 66 | ||
| 67 | export function readPrimaryEnvironmentDescriptor(): ExecutionEnvironmentDescriptor | null { | |
| 68 | return usePrimaryEnvironmentBootstrapStore.getState().descriptor; | |
| 69 | } | |
| 70 | ||
| 71 | export function usePrimaryEnvironmentId(): EnvironmentId | null { | |
| 72 | return usePrimaryEnvironmentBootstrapStore((state) => state.descriptor?.environmentId ?? null); | |
| 73 | } | |
| 74 | ||
| 75 | export function writePrimaryEnvironmentDescriptor( | |
| 76 | descriptor: ExecutionEnvironmentDescriptor | null, | |
| 77 | ): void { | |
| 78 | usePrimaryEnvironmentBootstrapStore.getState().setDescriptor(descriptor); | |
| 79 | } | |
| 80 | ||
| 81 | export function getPrimaryKnownEnvironment(): KnownEnvironment | null { | |
| 82 | const primaryTarget = readPrimaryEnvironmentTarget(); | |
| 83 | if (!primaryTarget) { | |
| 84 | return null; | |
| 85 | } | |
| 86 | ||
| 87 | return createPrimaryKnownEnvironment({ | |
| 88 | source: primaryTarget.source, | |
| 89 | target: primaryTarget.target, | |
| 90 | }); | |
| 91 | } | |
| 92 | ||
| 93 | export function resolveInitialPrimaryEnvironmentDescriptor(): Promise<ExecutionEnvironmentDescriptor> { | |
| 94 | const descriptor = readPrimaryEnvironmentDescriptor(); | |
| 95 | if (descriptor) { | |
| 96 | return Promise.resolve(descriptor); | |
| 97 | } | |
| 98 | ||
| 99 | if (primaryEnvironmentDescriptorPromise) { | |
| 100 | return primaryEnvironmentDescriptorPromise; | |
| 101 | } | |
| 102 | ||
| 103 | const nextPromise = fetchPrimaryEnvironmentDescriptor(); | |
| 104 | primaryEnvironmentDescriptorPromise = nextPromise; | |
| 105 | return nextPromise.finally(() => { | |
| 106 | if (primaryEnvironmentDescriptorPromise === nextPromise) { | |
| 107 | primaryEnvironmentDescriptorPromise = null; | |
| 108 | } | |
| 109 | }); | |
| 110 | } | |
| 111 | ||
| 112 | export function __resetPrimaryEnvironmentBootstrapForTests(): void { | |
| 113 | primaryEnvironmentDescriptorPromise = null; | |
| 114 | usePrimaryEnvironmentBootstrapStore.getState().reset(); | |
| 115 | } | |
| 116 | ||
| 117 | export const resetPrimaryEnvironmentDescriptorForTests = __resetPrimaryEnvironmentBootstrapForTests; | |
| 118 | ||
| 119 | export const __resetPrimaryEnvironmentDescriptorBootstrapForTests = | |
| 120 | __resetPrimaryEnvironmentBootstrapForTests; |
apps/web/src/environments/primary/index.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | export { | |
| 2 | getPrimaryKnownEnvironment, | |
| 3 | readPrimaryEnvironmentDescriptor, | |
| 4 | resetPrimaryEnvironmentDescriptorForTests, | |
| 5 | resolveInitialPrimaryEnvironmentDescriptor, | |
| 6 | usePrimaryEnvironmentId, | |
| 7 | writePrimaryEnvironmentDescriptor, | |
| 8 | __resetPrimaryEnvironmentBootstrapForTests, | |
| 9 | __resetPrimaryEnvironmentDescriptorBootstrapForTests, | |
| 10 | } from "./context"; | |
| 11 | ||
| 12 | export { | |
| 13 | resolveInitialPrimaryEnvironmentDescriptor as ensurePrimaryEnvironmentReady, | |
| 14 | writePrimaryEnvironmentDescriptor as updatePrimaryEnvironmentDescriptor, | |
| 15 | } from "./context"; | |
| 16 | ||
| 17 | export { | |
| 18 | createServerPairingCredential, | |
| 19 | fetchSessionState, | |
| 20 | listServerClientSessions, | |
| 21 | listServerPairingLinks, | |
| 22 | peekPairingTokenFromUrl, | |
| 23 | resolveInitialServerAuthGateState, | |
| 24 | revokeOtherServerClientSessions, | |
| 25 | revokeServerClientSession, | |
| 26 | revokeServerPairingLink, | |
| 27 | stripPairingTokenFromUrl, | |
| 28 | submitServerAuthCredential, | |
| 29 | takePairingTokenFromUrl, | |
| 30 | type ServerClientSessionRecord, | |
| 31 | type ServerPairingLinkRecord, | |
| 32 | __resetServerAuthBootstrapForTests, | |
| 33 | } from "./auth"; | |
| 34 | ||
| 35 | export { resolvePrimaryEnvironmentHttpUrl } from "./target"; |
apps/web/src/environments/primary/target.ts created+103| ... | ... | @@ -0,0 +1,103 @@ |
| 1 | import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | |
| 2 | import type { KnownEnvironment } from "@t3tools/client-runtime"; | |
| 3 | ||
| 4 | export interface PrimaryEnvironmentTarget { | |
| 5 | readonly source: KnownEnvironment["source"]; | |
| 6 | readonly target: KnownEnvironment["target"]; | |
| 7 | } | |
| 8 | ||
| 9 | function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null { | |
| 10 | return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null; | |
| 11 | } | |
| 12 | ||
| 13 | function normalizeBaseUrl(rawValue: string): string { | |
| 14 | return new URL(rawValue, window.location.origin).toString(); | |
| 15 | } | |
| 16 | ||
| 17 | function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { | |
| 18 | const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim(); | |
| 19 | const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim(); | |
| 20 | ||
| 21 | if (!configuredHttpBaseUrl && !configuredWsBaseUrl) { | |
| 22 | return null; | |
| 23 | } | |
| 24 | ||
| 25 | if (!configuredHttpBaseUrl || !configuredWsBaseUrl) { | |
| 26 | throw new Error("Configured primary environments require both VITE_HTTP_URL and VITE_WS_URL."); | |
| 27 | } | |
| 28 | ||
| 29 | return { | |
| 30 | source: "configured", | |
| 31 | target: { | |
| 32 | httpBaseUrl: normalizeBaseUrl(configuredHttpBaseUrl), | |
| 33 | wsBaseUrl: normalizeBaseUrl(configuredWsBaseUrl), | |
| 34 | }, | |
| 35 | }; | |
| 36 | } | |
| 37 | ||
| 38 | function resolveWindowOriginPrimaryTarget(): PrimaryEnvironmentTarget { | |
| 39 | const httpBaseUrl = normalizeBaseUrl(window.location.origin); | |
| 40 | const url = new URL(httpBaseUrl); | |
| 41 | if (url.protocol === "http:") { | |
| 42 | url.protocol = "ws:"; | |
| 43 | } else if (url.protocol === "https:") { | |
| 44 | url.protocol = "wss:"; | |
| 45 | } else { | |
| 46 | throw new Error(`Unsupported HTTP base URL protocol: ${url.protocol}`); | |
| 47 | } | |
| 48 | return { | |
| 49 | source: "window-origin", | |
| 50 | target: { | |
| 51 | httpBaseUrl, | |
| 52 | wsBaseUrl: url.toString(), | |
| 53 | }, | |
| 54 | }; | |
| 55 | } | |
| 56 | ||
| 57 | function resolveDesktopPrimaryTarget(): PrimaryEnvironmentTarget | null { | |
| 58 | const desktopBootstrap = getDesktopLocalEnvironmentBootstrap(); | |
| 59 | if (!desktopBootstrap) { | |
| 60 | return null; | |
| 61 | } | |
| 62 | if (!desktopBootstrap.httpBaseUrl && !desktopBootstrap.wsBaseUrl) { | |
| 63 | return null; | |
| 64 | } | |
| 65 | if (!desktopBootstrap.httpBaseUrl || !desktopBootstrap.wsBaseUrl) { | |
| 66 | throw new Error( | |
| 67 | "Desktop bootstrap must provide both httpBaseUrl and wsBaseUrl for the local environment.", | |
| 68 | ); | |
| 69 | } | |
| 70 | ||
| 71 | return { | |
| 72 | source: "desktop-managed", | |
| 73 | target: { | |
| 74 | httpBaseUrl: normalizeBaseUrl(desktopBootstrap.httpBaseUrl), | |
| 75 | wsBaseUrl: normalizeBaseUrl(desktopBootstrap.wsBaseUrl), | |
| 76 | }, | |
| 77 | }; | |
| 78 | } | |
| 79 | ||
| 80 | export function resolvePrimaryEnvironmentHttpUrl( | |
| 81 | pathname: string, | |
| 82 | searchParams?: Record<string, string>, | |
| 83 | ): string { | |
| 84 | const primaryTarget = readPrimaryEnvironmentTarget(); | |
| 85 | if (!primaryTarget) { | |
| 86 | throw new Error("Unable to resolve the primary environment HTTP base URL."); | |
| 87 | } | |
| 88 | ||
| 89 | const url = new URL(primaryTarget.target.httpBaseUrl); | |
| 90 | url.pathname = pathname; | |
| 91 | if (searchParams) { | |
| 92 | url.search = new URLSearchParams(searchParams).toString(); | |
| 93 | } | |
| 94 | return url.toString(); | |
| 95 | } | |
| 96 | ||
| 97 | export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { | |
| 98 | return ( | |
| 99 | resolveDesktopPrimaryTarget() ?? | |
| 100 | resolveConfiguredPrimaryTarget() ?? | |
| 101 | resolveWindowOriginPrimaryTarget() | |
| 102 | ); | |
| 103 | } |
apps/web/src/environments/remote/api.test.ts created+235| ... | ... | @@ -0,0 +1,235 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | bootstrapRemoteBearerSession, | |
| 5 | fetchRemoteEnvironmentDescriptor, | |
| 6 | fetchRemoteSessionState, | |
| 7 | issueRemoteWebSocketToken, | |
| 8 | resolveRemoteWebSocketConnectionUrl, | |
| 9 | } from "./api"; | |
| 10 | import { resolveRemotePairingTarget } from "./target"; | |
| 11 | ||
| 12 | const originalFetch = globalThis.fetch; | |
| 13 | ||
| 14 | beforeEach(() => { | |
| 15 | Object.defineProperty(globalThis, "window", { | |
| 16 | configurable: true, | |
| 17 | value: { | |
| 18 | location: { | |
| 19 | origin: "https://app.example.com", | |
| 20 | }, | |
| 21 | }, | |
| 22 | }); | |
| 23 | vi.restoreAllMocks(); | |
| 24 | }); | |
| 25 | ||
| 26 | describe("remote environment api", () => { | |
| 27 | it("derives backend urls and token from a pairing url", () => { | |
| 28 | expect( | |
| 29 | resolveRemotePairingTarget({ | |
| 30 | pairingUrl: "https://remote.example.com/pair#token=pairing-token", | |
| 31 | }), | |
| 32 | ).toEqual({ | |
| 33 | credential: "pairing-token", | |
| 34 | httpBaseUrl: "https://remote.example.com/", | |
| 35 | wsBaseUrl: "wss://remote.example.com/", | |
| 36 | }); | |
| 37 | }); | |
| 38 | ||
| 39 | it("accepts pairing urls that still use a query token", () => { | |
| 40 | expect( | |
| 41 | resolveRemotePairingTarget({ | |
| 42 | pairingUrl: "https://remote.example.com/pair?token=pairing-token", | |
| 43 | }), | |
| 44 | ).toEqual({ | |
| 45 | credential: "pairing-token", | |
| 46 | httpBaseUrl: "https://remote.example.com/", | |
| 47 | wsBaseUrl: "wss://remote.example.com/", | |
| 48 | }); | |
| 49 | }); | |
| 50 | ||
| 51 | it("derives backend urls from a host and pairing code", () => { | |
| 52 | expect( | |
| 53 | resolveRemotePairingTarget({ | |
| 54 | host: "https://remote.example.com", | |
| 55 | pairingCode: "pairing-token", | |
| 56 | }), | |
| 57 | ).toEqual({ | |
| 58 | credential: "pairing-token", | |
| 59 | httpBaseUrl: "https://remote.example.com/", | |
| 60 | wsBaseUrl: "wss://remote.example.com/", | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 64 | it("preserves host ports when normalizing a bare host input", () => { | |
| 65 | expect( | |
| 66 | resolveRemotePairingTarget({ | |
| 67 | host: "myserver.com:3000", | |
| 68 | pairingCode: "pairing-token", | |
| 69 | }), | |
| 70 | ).toEqual({ | |
| 71 | credential: "pairing-token", | |
| 72 | httpBaseUrl: "https://myserver.com:3000/", | |
| 73 | wsBaseUrl: "wss://myserver.com:3000/", | |
| 74 | }); | |
| 75 | }); | |
| 76 | ||
| 77 | it("bootstraps bearer auth against a remote backend", async () => { | |
| 78 | const fetchMock = vi.fn().mockResolvedValue( | |
| 79 | new Response( | |
| 80 | JSON.stringify({ | |
| 81 | authenticated: true, | |
| 82 | role: "client", | |
| 83 | sessionMethod: "bearer-session-token", | |
| 84 | expiresAt: "2026-05-01T12:00:00.000Z", | |
| 85 | sessionToken: "bearer-token", | |
| 86 | }), | |
| 87 | { status: 200 }, | |
| 88 | ), | |
| 89 | ); | |
| 90 | globalThis.fetch = fetchMock as typeof fetch; | |
| 91 | ||
| 92 | await expect( | |
| 93 | bootstrapRemoteBearerSession({ | |
| 94 | httpBaseUrl: "https://remote.example.com/", | |
| 95 | credential: "pairing-token", | |
| 96 | }), | |
| 97 | ).resolves.toMatchObject({ | |
| 98 | sessionMethod: "bearer-session-token", | |
| 99 | sessionToken: "bearer-token", | |
| 100 | }); | |
| 101 | ||
| 102 | expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/bootstrap/bearer", { | |
| 103 | method: "POST", | |
| 104 | headers: { | |
| 105 | "content-type": "application/json", | |
| 106 | }, | |
| 107 | body: JSON.stringify({ | |
| 108 | credential: "pairing-token", | |
| 109 | }), | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | it("loads remote session state and websocket tokens over bearer auth", async () => { | |
| 114 | const fetchMock = vi | |
| 115 | .fn() | |
| 116 | .mockResolvedValueOnce( | |
| 117 | new Response( | |
| 118 | JSON.stringify({ | |
| 119 | environmentId: "environment-remote", | |
| 120 | label: "Remote environment", | |
| 121 | platform: { | |
| 122 | os: "linux", | |
| 123 | arch: "x64", | |
| 124 | }, | |
| 125 | serverVersion: "0.0.0-test", | |
| 126 | capabilities: { | |
| 127 | repositoryIdentity: true, | |
| 128 | }, | |
| 129 | }), | |
| 130 | { status: 200 }, | |
| 131 | ), | |
| 132 | ) | |
| 133 | .mockResolvedValueOnce( | |
| 134 | new Response( | |
| 135 | JSON.stringify({ | |
| 136 | authenticated: true, | |
| 137 | auth: { | |
| 138 | policy: "remote-reachable", | |
| 139 | bootstrapMethods: ["one-time-token"], | |
| 140 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 141 | sessionCookieName: "t3_session", | |
| 142 | }, | |
| 143 | role: "client", | |
| 144 | sessionMethod: "bearer-session-token", | |
| 145 | expiresAt: "2026-05-01T12:00:00.000Z", | |
| 146 | }), | |
| 147 | { status: 200 }, | |
| 148 | ), | |
| 149 | ) | |
| 150 | .mockResolvedValueOnce( | |
| 151 | new Response( | |
| 152 | JSON.stringify({ | |
| 153 | token: "ws-token", | |
| 154 | expiresAt: "2026-05-01T12:05:00.000Z", | |
| 155 | }), | |
| 156 | { status: 200 }, | |
| 157 | ), | |
| 158 | ); | |
| 159 | globalThis.fetch = fetchMock as typeof fetch; | |
| 160 | ||
| 161 | await expect( | |
| 162 | fetchRemoteEnvironmentDescriptor({ | |
| 163 | httpBaseUrl: "https://remote.example.com/", | |
| 164 | }), | |
| 165 | ).resolves.toMatchObject({ | |
| 166 | environmentId: "environment-remote", | |
| 167 | label: "Remote environment", | |
| 168 | }); | |
| 169 | ||
| 170 | await expect( | |
| 171 | fetchRemoteSessionState({ | |
| 172 | httpBaseUrl: "https://remote.example.com/", | |
| 173 | bearerToken: "bearer-token", | |
| 174 | }), | |
| 175 | ).resolves.toMatchObject({ | |
| 176 | authenticated: true, | |
| 177 | role: "client", | |
| 178 | }); | |
| 179 | ||
| 180 | await expect( | |
| 181 | issueRemoteWebSocketToken({ | |
| 182 | httpBaseUrl: "https://remote.example.com/", | |
| 183 | bearerToken: "bearer-token", | |
| 184 | }), | |
| 185 | ).resolves.toMatchObject({ | |
| 186 | token: "ws-token", | |
| 187 | }); | |
| 188 | ||
| 189 | expect(fetchMock).toHaveBeenNthCalledWith( | |
| 190 | 1, | |
| 191 | "https://remote.example.com/.well-known/t3/environment", | |
| 192 | { | |
| 193 | method: "GET", | |
| 194 | headers: {}, | |
| 195 | }, | |
| 196 | ); | |
| 197 | expect(fetchMock).toHaveBeenNthCalledWith(2, "https://remote.example.com/api/auth/session", { | |
| 198 | method: "GET", | |
| 199 | headers: { | |
| 200 | authorization: "Bearer bearer-token", | |
| 201 | }, | |
| 202 | }); | |
| 203 | expect(fetchMock).toHaveBeenNthCalledWith(3, "https://remote.example.com/api/auth/ws-token", { | |
| 204 | method: "POST", | |
| 205 | headers: { | |
| 206 | authorization: "Bearer bearer-token", | |
| 207 | }, | |
| 208 | }); | |
| 209 | }); | |
| 210 | ||
| 211 | it("mints a websocket url with a short-lived ws token", async () => { | |
| 212 | const fetchMock = vi.fn().mockResolvedValue( | |
| 213 | new Response( | |
| 214 | JSON.stringify({ | |
| 215 | token: "ws-token", | |
| 216 | expiresAt: "2026-05-01T12:05:00.000Z", | |
| 217 | }), | |
| 218 | { status: 200 }, | |
| 219 | ), | |
| 220 | ); | |
| 221 | globalThis.fetch = fetchMock as typeof fetch; | |
| 222 | ||
| 223 | await expect( | |
| 224 | resolveRemoteWebSocketConnectionUrl({ | |
| 225 | wsBaseUrl: "wss://remote.example.com/", | |
| 226 | httpBaseUrl: "https://remote.example.com/", | |
| 227 | bearerToken: "bearer-token", | |
| 228 | }), | |
| 229 | ).resolves.toBe("wss://remote.example.com/?wsToken=ws-token"); | |
| 230 | }); | |
| 231 | }); | |
| 232 | ||
| 233 | afterEach(() => { | |
| 234 | globalThis.fetch = originalFetch; | |
| 235 | }); |
apps/web/src/environments/remote/api.ts created+143| ... | ... | @@ -0,0 +1,143 @@ |
| 1 | import type { | |
| 2 | AuthBearerBootstrapResult, | |
| 3 | AuthSessionState, | |
| 4 | AuthWebSocketTokenResult, | |
| 5 | ExecutionEnvironmentDescriptor, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | class RemoteEnvironmentAuthHttpError extends Error { | |
| 9 | readonly status: number; | |
| 10 | ||
| 11 | constructor(message: string, status: number) { | |
| 12 | super(message); | |
| 13 | this.name = "RemoteEnvironmentAuthHttpError"; | |
| 14 | this.status = status; | |
| 15 | } | |
| 16 | } | |
| 17 | ||
| 18 | function remoteEndpointUrl(httpBaseUrl: string, pathname: string): string { | |
| 19 | const url = new URL(httpBaseUrl); | |
| 20 | url.pathname = pathname; | |
| 21 | url.search = ""; | |
| 22 | url.hash = ""; | |
| 23 | return url.toString(); | |
| 24 | } | |
| 25 | ||
| 26 | async function readRemoteAuthErrorMessage( | |
| 27 | response: Response, | |
| 28 | fallbackMessage: string, | |
| 29 | ): Promise<string> { | |
| 30 | const text = await response.text(); | |
| 31 | if (!text) { | |
| 32 | return fallbackMessage; | |
| 33 | } | |
| 34 | ||
| 35 | try { | |
| 36 | const parsed = JSON.parse(text) as { readonly error?: string }; | |
| 37 | if (typeof parsed.error === "string" && parsed.error.length > 0) { | |
| 38 | return parsed.error; | |
| 39 | } | |
| 40 | } catch { | |
| 41 | // Fall back to raw text below. | |
| 42 | } | |
| 43 | ||
| 44 | return text; | |
| 45 | } | |
| 46 | ||
| 47 | async function fetchRemoteJson<T>(input: { | |
| 48 | readonly httpBaseUrl: string; | |
| 49 | readonly pathname: string; | |
| 50 | readonly method?: "GET" | "POST"; | |
| 51 | readonly bearerToken?: string; | |
| 52 | readonly body?: unknown; | |
| 53 | }): Promise<T> { | |
| 54 | const requestUrl = remoteEndpointUrl(input.httpBaseUrl, input.pathname); | |
| 55 | let response: Response; | |
| 56 | try { | |
| 57 | response = await fetch(requestUrl, { | |
| 58 | method: input.method ?? "GET", | |
| 59 | headers: { | |
| 60 | ...(input.body !== undefined ? { "content-type": "application/json" } : {}), | |
| 61 | ...(input.bearerToken ? { authorization: `Bearer ${input.bearerToken}` } : {}), | |
| 62 | }, | |
| 63 | ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}), | |
| 64 | }); | |
| 65 | } catch (error) { | |
| 66 | throw new Error( | |
| 67 | `Failed to fetch remote auth endpoint ${requestUrl} (${(error as Error).message}).`, | |
| 68 | { cause: error }, | |
| 69 | ); | |
| 70 | } | |
| 71 | ||
| 72 | if (!response.ok) { | |
| 73 | throw new RemoteEnvironmentAuthHttpError( | |
| 74 | await readRemoteAuthErrorMessage( | |
| 75 | response, | |
| 76 | `Remote auth request failed (${response.status}).`, | |
| 77 | ), | |
| 78 | response.status, | |
| 79 | ); | |
| 80 | } | |
| 81 | ||
| 82 | return (await response.json()) as T; | |
| 83 | } | |
| 84 | ||
| 85 | export async function bootstrapRemoteBearerSession(input: { | |
| 86 | readonly httpBaseUrl: string; | |
| 87 | readonly credential: string; | |
| 88 | }): Promise<AuthBearerBootstrapResult> { | |
| 89 | return fetchRemoteJson<AuthBearerBootstrapResult>({ | |
| 90 | httpBaseUrl: input.httpBaseUrl, | |
| 91 | pathname: "/api/auth/bootstrap/bearer", | |
| 92 | method: "POST", | |
| 93 | body: { | |
| 94 | credential: input.credential, | |
| 95 | }, | |
| 96 | }); | |
| 97 | } | |
| 98 | ||
| 99 | export async function fetchRemoteSessionState(input: { | |
| 100 | readonly httpBaseUrl: string; | |
| 101 | readonly bearerToken: string; | |
| 102 | }): Promise<AuthSessionState> { | |
| 103 | return fetchRemoteJson<AuthSessionState>({ | |
| 104 | httpBaseUrl: input.httpBaseUrl, | |
| 105 | pathname: "/api/auth/session", | |
| 106 | bearerToken: input.bearerToken, | |
| 107 | }); | |
| 108 | } | |
| 109 | ||
| 110 | export async function fetchRemoteEnvironmentDescriptor(input: { | |
| 111 | readonly httpBaseUrl: string; | |
| 112 | }): Promise<ExecutionEnvironmentDescriptor> { | |
| 113 | return fetchRemoteJson<ExecutionEnvironmentDescriptor>({ | |
| 114 | httpBaseUrl: input.httpBaseUrl, | |
| 115 | pathname: "/.well-known/t3/environment", | |
| 116 | }); | |
| 117 | } | |
| 118 | ||
| 119 | export async function issueRemoteWebSocketToken(input: { | |
| 120 | readonly httpBaseUrl: string; | |
| 121 | readonly bearerToken: string; | |
| 122 | }): Promise<AuthWebSocketTokenResult> { | |
| 123 | return fetchRemoteJson<AuthWebSocketTokenResult>({ | |
| 124 | httpBaseUrl: input.httpBaseUrl, | |
| 125 | pathname: "/api/auth/ws-token", | |
| 126 | method: "POST", | |
| 127 | bearerToken: input.bearerToken, | |
| 128 | }); | |
| 129 | } | |
| 130 | ||
| 131 | export async function resolveRemoteWebSocketConnectionUrl(input: { | |
| 132 | readonly wsBaseUrl: string; | |
| 133 | readonly httpBaseUrl: string; | |
| 134 | readonly bearerToken: string; | |
| 135 | }): Promise<string> { | |
| 136 | const issued = await issueRemoteWebSocketToken({ | |
| 137 | httpBaseUrl: input.httpBaseUrl, | |
| 138 | bearerToken: input.bearerToken, | |
| 139 | }); | |
| 140 | const url = new URL(input.wsBaseUrl, window.location.origin); | |
| 141 | url.searchParams.set("wsToken", issued.token); | |
| 142 | return url.toString(); | |
| 143 | } |
apps/web/src/environments/remote/target.ts created+86| ... | ... | @@ -0,0 +1,86 @@ |
| 1 | import { getPairingTokenFromUrl } from "../../pairingUrl"; | |
| 2 | ||
| 3 | export interface ResolvedRemotePairingTarget { | |
| 4 | readonly credential: string; | |
| 5 | readonly httpBaseUrl: string; | |
| 6 | readonly wsBaseUrl: string; | |
| 7 | } | |
| 8 | ||
| 9 | function normalizeRemoteBaseUrl(rawValue: string): URL { | |
| 10 | const trimmed = rawValue.trim(); | |
| 11 | if (!trimmed) { | |
| 12 | throw new Error("Enter a backend URL."); | |
| 13 | } | |
| 14 | ||
| 15 | const normalizedInput = | |
| 16 | /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//") | |
| 17 | ? trimmed | |
| 18 | : `https://${trimmed}`; | |
| 19 | const url = new URL(normalizedInput, window.location.origin); | |
| 20 | url.pathname = "/"; | |
| 21 | url.search = ""; | |
| 22 | url.hash = ""; | |
| 23 | return url; | |
| 24 | } | |
| 25 | ||
| 26 | function toHttpBaseUrl(url: URL): string { | |
| 27 | const next = new URL(url.toString()); | |
| 28 | if (next.protocol === "ws:") { | |
| 29 | next.protocol = "http:"; | |
| 30 | } else if (next.protocol === "wss:") { | |
| 31 | next.protocol = "https:"; | |
| 32 | } | |
| 33 | next.pathname = "/"; | |
| 34 | next.search = ""; | |
| 35 | next.hash = ""; | |
| 36 | return next.toString(); | |
| 37 | } | |
| 38 | ||
| 39 | function toWsBaseUrl(url: URL): string { | |
| 40 | const next = new URL(url.toString()); | |
| 41 | if (next.protocol === "http:") { | |
| 42 | next.protocol = "ws:"; | |
| 43 | } else if (next.protocol === "https:") { | |
| 44 | next.protocol = "wss:"; | |
| 45 | } | |
| 46 | next.pathname = "/"; | |
| 47 | next.search = ""; | |
| 48 | next.hash = ""; | |
| 49 | return next.toString(); | |
| 50 | } | |
| 51 | ||
| 52 | export function resolveRemotePairingTarget(input: { | |
| 53 | readonly pairingUrl?: string; | |
| 54 | readonly host?: string; | |
| 55 | readonly pairingCode?: string; | |
| 56 | }): ResolvedRemotePairingTarget { | |
| 57 | const pairingUrl = input.pairingUrl?.trim() ?? ""; | |
| 58 | if (pairingUrl.length > 0) { | |
| 59 | const url = new URL(pairingUrl, window.location.origin); | |
| 60 | const credential = getPairingTokenFromUrl(url) ?? ""; | |
| 61 | if (!credential) { | |
| 62 | throw new Error("Pairing URL is missing its token."); | |
| 63 | } | |
| 64 | return { | |
| 65 | credential, | |
| 66 | httpBaseUrl: toHttpBaseUrl(url), | |
| 67 | wsBaseUrl: toWsBaseUrl(url), | |
| 68 | }; | |
| 69 | } | |
| 70 | ||
| 71 | const host = input.host?.trim() ?? ""; | |
| 72 | const pairingCode = input.pairingCode?.trim() ?? ""; | |
| 73 | if (!host) { | |
| 74 | throw new Error("Enter a backend URL."); | |
| 75 | } | |
| 76 | if (!pairingCode) { | |
| 77 | throw new Error("Enter a pairing code."); | |
| 78 | } | |
| 79 | ||
| 80 | const normalizedHost = normalizeRemoteBaseUrl(host); | |
| 81 | return { | |
| 82 | credential: pairingCode, | |
| 83 | httpBaseUrl: toHttpBaseUrl(normalizedHost), | |
| 84 | wsBaseUrl: toWsBaseUrl(normalizedHost), | |
| 85 | }; | |
| 86 | } |
apps/web/src/environments/runtime/catalog.test.ts created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { afterEach, describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { | |
| 5 | resetSavedEnvironmentRegistryStoreForTests, | |
| 6 | resetSavedEnvironmentRuntimeStoreForTests, | |
| 7 | useSavedEnvironmentRegistryStore, | |
| 8 | useSavedEnvironmentRuntimeStore, | |
| 9 | } from "./catalog"; | |
| 10 | ||
| 11 | describe("environment runtime catalog stores", () => { | |
| 12 | afterEach(() => { | |
| 13 | resetSavedEnvironmentRegistryStoreForTests(); | |
| 14 | resetSavedEnvironmentRuntimeStoreForTests(); | |
| 15 | }); | |
| 16 | ||
| 17 | it("resets the saved environment registry store state", () => { | |
| 18 | const environmentId = EnvironmentId.makeUnsafe("environment-1"); | |
| 19 | ||
| 20 | useSavedEnvironmentRegistryStore.getState().upsert({ | |
| 21 | environmentId, | |
| 22 | label: "Remote environment", | |
| 23 | httpBaseUrl: "https://remote.example.com/", | |
| 24 | wsBaseUrl: "wss://remote.example.com/", | |
| 25 | bearerToken: "token", | |
| 26 | createdAt: "2026-04-09T00:00:00.000Z", | |
| 27 | lastConnectedAt: null, | |
| 28 | }); | |
| 29 | ||
| 30 | expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toBeDefined(); | |
| 31 | ||
| 32 | resetSavedEnvironmentRegistryStoreForTests(); | |
| 33 | ||
| 34 | expect(useSavedEnvironmentRegistryStore.getState().byId).toEqual({}); | |
| 35 | }); | |
| 36 | ||
| 37 | it("resets the saved environment runtime store state", () => { | |
| 38 | const environmentId = EnvironmentId.makeUnsafe("environment-1"); | |
| 39 | ||
| 40 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | |
| 41 | connectionState: "connected", | |
| 42 | connectedAt: "2026-04-09T00:00:00.000Z", | |
| 43 | }); | |
| 44 | ||
| 45 | expect(useSavedEnvironmentRuntimeStore.getState().byId[environmentId]).toBeDefined(); | |
| 46 | ||
| 47 | resetSavedEnvironmentRuntimeStoreForTests(); | |
| 48 | ||
| 49 | expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); | |
| 50 | }); | |
| 51 | }); |
apps/web/src/environments/runtime/catalog.ts created+251| ... | ... | @@ -0,0 +1,251 @@ |
| 1 | import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; | |
| 2 | import type { | |
| 3 | AuthSessionRole, | |
| 4 | EnvironmentId, | |
| 5 | ExecutionEnvironmentDescriptor, | |
| 6 | ServerConfig, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { create } from "zustand"; | |
| 9 | import { createJSONStorage, persist } from "zustand/middleware"; | |
| 10 | ||
| 11 | import { resolveStorage } from "../../lib/storage"; | |
| 12 | import { getPrimaryKnownEnvironment } from "../primary"; | |
| 13 | ||
| 14 | const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; | |
| 15 | ||
| 16 | export interface SavedEnvironmentRecord { | |
| 17 | readonly environmentId: EnvironmentId; | |
| 18 | readonly label: string; | |
| 19 | readonly wsBaseUrl: string; | |
| 20 | readonly httpBaseUrl: string; | |
| 21 | readonly bearerToken: string; | |
| 22 | readonly createdAt: string; | |
| 23 | readonly lastConnectedAt: string | null; | |
| 24 | } | |
| 25 | ||
| 26 | interface SavedEnvironmentRegistryState { | |
| 27 | readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>; | |
| 28 | } | |
| 29 | ||
| 30 | interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { | |
| 31 | readonly upsert: (record: SavedEnvironmentRecord) => void; | |
| 32 | readonly remove: (environmentId: EnvironmentId) => void; | |
| 33 | readonly markConnected: (environmentId: EnvironmentId, connectedAt: string) => void; | |
| 34 | readonly reset: () => void; | |
| 35 | } | |
| 36 | ||
| 37 | function createSavedEnvironmentRegistryStorage() { | |
| 38 | return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); | |
| 39 | } | |
| 40 | ||
| 41 | export const useSavedEnvironmentRegistryStore = create<SavedEnvironmentRegistryStore>()( | |
| 42 | persist( | |
| 43 | (set) => ({ | |
| 44 | byId: {}, | |
| 45 | upsert: (record) => | |
| 46 | set((state) => ({ | |
| 47 | byId: { | |
| 48 | ...state.byId, | |
| 49 | [record.environmentId]: record, | |
| 50 | }, | |
| 51 | })), | |
| 52 | remove: (environmentId) => | |
| 53 | set((state) => { | |
| 54 | const { [environmentId]: _removed, ...remaining } = state.byId; | |
| 55 | return { | |
| 56 | byId: remaining, | |
| 57 | }; | |
| 58 | }), | |
| 59 | markConnected: (environmentId, connectedAt) => | |
| 60 | set((state) => { | |
| 61 | const existing = state.byId[environmentId]; | |
| 62 | if (!existing) { | |
| 63 | return state; | |
| 64 | } | |
| 65 | return { | |
| 66 | byId: { | |
| 67 | ...state.byId, | |
| 68 | [environmentId]: { | |
| 69 | ...existing, | |
| 70 | lastConnectedAt: connectedAt, | |
| 71 | }, | |
| 72 | }, | |
| 73 | }; | |
| 74 | }), | |
| 75 | reset: () => | |
| 76 | set({ | |
| 77 | byId: {}, | |
| 78 | }), | |
| 79 | }), | |
| 80 | { | |
| 81 | name: SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, | |
| 82 | version: 1, | |
| 83 | storage: createJSONStorage(createSavedEnvironmentRegistryStorage), | |
| 84 | partialize: (state) => ({ | |
| 85 | byId: state.byId, | |
| 86 | }), | |
| 87 | }, | |
| 88 | ), | |
| 89 | ); | |
| 90 | ||
| 91 | export function hasSavedEnvironmentRegistryHydrated(): boolean { | |
| 92 | return useSavedEnvironmentRegistryStore.persist.hasHydrated(); | |
| 93 | } | |
| 94 | ||
| 95 | export function waitForSavedEnvironmentRegistryHydration(): Promise<void> { | |
| 96 | if (hasSavedEnvironmentRegistryHydrated()) { | |
| 97 | return Promise.resolve(); | |
| 98 | } | |
| 99 | ||
| 100 | return new Promise((resolve) => { | |
| 101 | const unsubscribe = useSavedEnvironmentRegistryStore.persist.onFinishHydration(() => { | |
| 102 | unsubscribe(); | |
| 103 | resolve(); | |
| 104 | }); | |
| 105 | ||
| 106 | if (hasSavedEnvironmentRegistryHydrated()) { | |
| 107 | unsubscribe(); | |
| 108 | resolve(); | |
| 109 | } | |
| 110 | }); | |
| 111 | } | |
| 112 | ||
| 113 | export function listSavedEnvironmentRecords(): ReadonlyArray<SavedEnvironmentRecord> { | |
| 114 | return Object.values(useSavedEnvironmentRegistryStore.getState().byId).toSorted((left, right) => | |
| 115 | left.label.localeCompare(right.label), | |
| 116 | ); | |
| 117 | } | |
| 118 | ||
| 119 | export function getSavedEnvironmentRecord( | |
| 120 | environmentId: EnvironmentId, | |
| 121 | ): SavedEnvironmentRecord | null { | |
| 122 | return useSavedEnvironmentRegistryStore.getState().byId[environmentId] ?? null; | |
| 123 | } | |
| 124 | ||
| 125 | export function getEnvironmentHttpBaseUrl(environmentId: EnvironmentId): string | null { | |
| 126 | const primaryEnvironment = getPrimaryKnownEnvironment(); | |
| 127 | if (primaryEnvironment?.environmentId === environmentId) { | |
| 128 | return getKnownEnvironmentHttpBaseUrl(primaryEnvironment); | |
| 129 | } | |
| 130 | ||
| 131 | return getSavedEnvironmentRecord(environmentId)?.httpBaseUrl ?? null; | |
| 132 | } | |
| 133 | ||
| 134 | export function resolveEnvironmentHttpUrl(input: { | |
| 135 | readonly environmentId: EnvironmentId; | |
| 136 | readonly pathname: string; | |
| 137 | readonly searchParams?: Record<string, string>; | |
| 138 | }): string { | |
| 139 | const httpBaseUrl = getEnvironmentHttpBaseUrl(input.environmentId); | |
| 140 | if (!httpBaseUrl) { | |
| 141 | throw new Error(`Unable to resolve HTTP base URL for environment ${input.environmentId}.`); | |
| 142 | } | |
| 143 | ||
| 144 | const url = new URL(httpBaseUrl); | |
| 145 | url.pathname = input.pathname; | |
| 146 | if (input.searchParams) { | |
| 147 | url.search = new URLSearchParams(input.searchParams).toString(); | |
| 148 | } | |
| 149 | return url.toString(); | |
| 150 | } | |
| 151 | ||
| 152 | export function resetSavedEnvironmentRegistryStoreForTests() { | |
| 153 | useSavedEnvironmentRegistryStore.getState().reset(); | |
| 154 | } | |
| 155 | ||
| 156 | export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error"; | |
| 157 | ||
| 158 | export type SavedEnvironmentAuthState = "authenticated" | "requires-auth" | "unknown"; | |
| 159 | ||
| 160 | export interface SavedEnvironmentRuntimeState { | |
| 161 | readonly connectionState: SavedEnvironmentConnectionState; | |
| 162 | readonly authState: SavedEnvironmentAuthState; | |
| 163 | readonly lastError: string | null; | |
| 164 | readonly lastErrorAt: string | null; | |
| 165 | readonly role: AuthSessionRole | null; | |
| 166 | readonly descriptor: ExecutionEnvironmentDescriptor | null; | |
| 167 | readonly serverConfig: ServerConfig | null; | |
| 168 | readonly connectedAt: string | null; | |
| 169 | readonly disconnectedAt: string | null; | |
| 170 | } | |
| 171 | ||
| 172 | interface SavedEnvironmentRuntimeStoreState { | |
| 173 | readonly byId: Record<EnvironmentId, SavedEnvironmentRuntimeState>; | |
| 174 | readonly ensure: (environmentId: EnvironmentId) => void; | |
| 175 | readonly patch: ( | |
| 176 | environmentId: EnvironmentId, | |
| 177 | patch: Partial<SavedEnvironmentRuntimeState>, | |
| 178 | ) => void; | |
| 179 | readonly clear: (environmentId: EnvironmentId) => void; | |
| 180 | readonly reset: () => void; | |
| 181 | } | |
| 182 | ||
| 183 | const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Object.freeze({ | |
| 184 | connectionState: "disconnected", | |
| 185 | authState: "unknown", | |
| 186 | lastError: null, | |
| 187 | lastErrorAt: null, | |
| 188 | role: null, | |
| 189 | descriptor: null, | |
| 190 | serverConfig: null, | |
| 191 | connectedAt: null, | |
| 192 | disconnectedAt: null, | |
| 193 | }); | |
| 194 | ||
| 195 | function createDefaultSavedEnvironmentRuntimeState(): SavedEnvironmentRuntimeState { | |
| 196 | return { | |
| 197 | ...DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE, | |
| 198 | }; | |
| 199 | } | |
| 200 | ||
| 201 | export const useSavedEnvironmentRuntimeStore = create<SavedEnvironmentRuntimeStoreState>()( | |
| 202 | (set) => ({ | |
| 203 | byId: {}, | |
| 204 | ensure: (environmentId) => | |
| 205 | set((state) => { | |
| 206 | if (state.byId[environmentId]) { | |
| 207 | return state; | |
| 208 | } | |
| 209 | return { | |
| 210 | byId: { | |
| 211 | ...state.byId, | |
| 212 | [environmentId]: createDefaultSavedEnvironmentRuntimeState(), | |
| 213 | }, | |
| 214 | }; | |
| 215 | }), | |
| 216 | patch: (environmentId, patch) => | |
| 217 | set((state) => ({ | |
| 218 | byId: { | |
| 219 | ...state.byId, | |
| 220 | [environmentId]: { | |
| 221 | ...(state.byId[environmentId] ?? createDefaultSavedEnvironmentRuntimeState()), | |
| 222 | ...patch, | |
| 223 | }, | |
| 224 | }, | |
| 225 | })), | |
| 226 | clear: (environmentId) => | |
| 227 | set((state) => { | |
| 228 | const { [environmentId]: _removed, ...remaining } = state.byId; | |
| 229 | return { | |
| 230 | byId: remaining, | |
| 231 | }; | |
| 232 | }), | |
| 233 | reset: () => | |
| 234 | set({ | |
| 235 | byId: {}, | |
| 236 | }), | |
| 237 | }), | |
| 238 | ); | |
| 239 | ||
| 240 | export function getSavedEnvironmentRuntimeState( | |
| 241 | environmentId: EnvironmentId, | |
| 242 | ): SavedEnvironmentRuntimeState { | |
| 243 | return ( | |
| 244 | useSavedEnvironmentRuntimeStore.getState().byId[environmentId] ?? | |
| 245 | DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE | |
| 246 | ); | |
| 247 | } | |
| 248 | ||
| 249 | export function resetSavedEnvironmentRuntimeStoreForTests() { | |
| 250 | useSavedEnvironmentRuntimeStore.getState().reset(); | |
| 251 | } |
apps/web/src/environments/runtime/connection.test.ts created+216| ... | ... | @@ -0,0 +1,216 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it, vi } from "vitest"; | |
| 3 | ||
| 4 | import { createEnvironmentConnection } from "./connection"; | |
| 5 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | |
| 6 | ||
| 7 | function createTestClient(options?: { | |
| 8 | readonly getSnapshot?: () => Promise<{ readonly snapshotSequence: number }>; | |
| 9 | }) { | |
| 10 | const lifecycleListeners = new Set<(event: any) => void>(); | |
| 11 | const configListeners = new Set<(event: any) => void>(); | |
| 12 | const terminalListeners = new Set<(event: any) => void>(); | |
| 13 | ||
| 14 | const getSnapshot = vi.fn( | |
| 15 | options?.getSnapshot ?? | |
| 16 | (async () => | |
| 17 | ({ | |
| 18 | snapshotSequence: 1, | |
| 19 | projects: [], | |
| 20 | threads: [], | |
| 21 | }) as any), | |
| 22 | ); | |
| 23 | ||
| 24 | const client = { | |
| 25 | dispose: vi.fn(async () => undefined), | |
| 26 | reconnect: vi.fn(async () => undefined), | |
| 27 | server: { | |
| 28 | getConfig: vi.fn(async () => ({ | |
| 29 | environment: { | |
| 30 | environmentId: EnvironmentId.makeUnsafe("env-1"), | |
| 31 | }, | |
| 32 | })), | |
| 33 | subscribeConfig: (listener: (event: any) => void) => { | |
| 34 | configListeners.add(listener); | |
| 35 | return () => configListeners.delete(listener); | |
| 36 | }, | |
| 37 | subscribeLifecycle: (listener: (event: any) => void) => { | |
| 38 | lifecycleListeners.add(listener); | |
| 39 | return () => lifecycleListeners.delete(listener); | |
| 40 | }, | |
| 41 | subscribeAuthAccess: () => () => undefined, | |
| 42 | refreshProviders: vi.fn(async () => undefined), | |
| 43 | upsertKeybinding: vi.fn(async () => undefined), | |
| 44 | getSettings: vi.fn(async () => undefined), | |
| 45 | updateSettings: vi.fn(async () => undefined), | |
| 46 | }, | |
| 47 | orchestration: { | |
| 48 | getSnapshot, | |
| 49 | dispatchCommand: vi.fn(async () => undefined), | |
| 50 | getTurnDiff: vi.fn(async () => undefined), | |
| 51 | getFullThreadDiff: vi.fn(async () => undefined), | |
| 52 | replayEvents: vi.fn(async () => []), | |
| 53 | onDomainEvent: () => () => undefined, | |
| 54 | }, | |
| 55 | terminal: { | |
| 56 | open: vi.fn(async () => undefined), | |
| 57 | write: vi.fn(async () => undefined), | |
| 58 | resize: vi.fn(async () => undefined), | |
| 59 | clear: vi.fn(async () => undefined), | |
| 60 | restart: vi.fn(async () => undefined), | |
| 61 | close: vi.fn(async () => undefined), | |
| 62 | onEvent: (listener: (event: any) => void) => { | |
| 63 | terminalListeners.add(listener); | |
| 64 | return () => terminalListeners.delete(listener); | |
| 65 | }, | |
| 66 | }, | |
| 67 | projects: { | |
| 68 | searchEntries: vi.fn(async () => []), | |
| 69 | writeFile: vi.fn(async () => undefined), | |
| 70 | }, | |
| 71 | shell: { | |
| 72 | openInEditor: vi.fn(async () => undefined), | |
| 73 | }, | |
| 74 | git: { | |
| 75 | pull: vi.fn(async () => undefined), | |
| 76 | refreshStatus: vi.fn(async () => undefined), | |
| 77 | onStatus: vi.fn(() => () => undefined), | |
| 78 | runStackedAction: vi.fn(async () => ({}) as any), | |
| 79 | listBranches: vi.fn(async () => []), | |
| 80 | createWorktree: vi.fn(async () => undefined), | |
| 81 | removeWorktree: vi.fn(async () => undefined), | |
| 82 | createBranch: vi.fn(async () => undefined), | |
| 83 | checkout: vi.fn(async () => undefined), | |
| 84 | init: vi.fn(async () => undefined), | |
| 85 | resolvePullRequest: vi.fn(async () => undefined), | |
| 86 | preparePullRequestThread: vi.fn(async () => undefined), | |
| 87 | }, | |
| 88 | } as unknown as WsRpcClient; | |
| 89 | ||
| 90 | return { | |
| 91 | client, | |
| 92 | getSnapshot, | |
| 93 | emitWelcome: (environmentId: EnvironmentId) => { | |
| 94 | for (const listener of lifecycleListeners) { | |
| 95 | listener({ | |
| 96 | type: "welcome", | |
| 97 | payload: { | |
| 98 | environment: { | |
| 99 | environmentId, | |
| 100 | }, | |
| 101 | }, | |
| 102 | }); | |
| 103 | } | |
| 104 | }, | |
| 105 | emitConfigSnapshot: (environmentId: EnvironmentId) => { | |
| 106 | for (const listener of configListeners) { | |
| 107 | listener({ | |
| 108 | type: "snapshot", | |
| 109 | config: { | |
| 110 | environment: { | |
| 111 | environmentId, | |
| 112 | }, | |
| 113 | }, | |
| 114 | }); | |
| 115 | } | |
| 116 | }, | |
| 117 | }; | |
| 118 | } | |
| 119 | ||
| 120 | describe("createEnvironmentConnection", () => { | |
| 121 | it("bootstraps a snapshot immediately for a new connection", async () => { | |
| 122 | const environmentId = EnvironmentId.makeUnsafe("env-1"); | |
| 123 | const { client, getSnapshot } = createTestClient(); | |
| 124 | const syncSnapshot = vi.fn(); | |
| 125 | ||
| 126 | const connection = createEnvironmentConnection({ | |
| 127 | kind: "saved", | |
| 128 | knownEnvironment: { | |
| 129 | id: "env-1", | |
| 130 | label: "Remote env", | |
| 131 | source: "manual", | |
| 132 | target: { | |
| 133 | httpBaseUrl: "http://example.test", | |
| 134 | wsBaseUrl: "ws://example.test", | |
| 135 | }, | |
| 136 | environmentId, | |
| 137 | }, | |
| 138 | client, | |
| 139 | applyEventBatch: vi.fn(), | |
| 140 | syncSnapshot, | |
| 141 | applyTerminalEvent: vi.fn(), | |
| 142 | }); | |
| 143 | ||
| 144 | await Promise.resolve(); | |
| 145 | await Promise.resolve(); | |
| 146 | ||
| 147 | expect(getSnapshot).toHaveBeenCalledTimes(1); | |
| 148 | expect(syncSnapshot).toHaveBeenCalledWith( | |
| 149 | expect.objectContaining({ snapshotSequence: 1 }), | |
| 150 | environmentId, | |
| 151 | ); | |
| 152 | ||
| 153 | await connection.dispose(); | |
| 154 | }); | |
| 155 | ||
| 156 | it("rejects welcome/config identity drift", async () => { | |
| 157 | const environmentId = EnvironmentId.makeUnsafe("env-1"); | |
| 158 | const { client, emitWelcome } = createTestClient(); | |
| 159 | ||
| 160 | const connection = createEnvironmentConnection({ | |
| 161 | kind: "saved", | |
| 162 | knownEnvironment: { | |
| 163 | id: "env-1", | |
| 164 | label: "Remote env", | |
| 165 | source: "manual", | |
| 166 | target: { | |
| 167 | httpBaseUrl: "http://example.test", | |
| 168 | wsBaseUrl: "ws://example.test", | |
| 169 | }, | |
| 170 | environmentId, | |
| 171 | }, | |
| 172 | client, | |
| 173 | applyEventBatch: vi.fn(), | |
| 174 | syncSnapshot: vi.fn(), | |
| 175 | applyTerminalEvent: vi.fn(), | |
| 176 | }); | |
| 177 | ||
| 178 | expect(() => emitWelcome(EnvironmentId.makeUnsafe("env-2"))).toThrow( | |
| 179 | "Environment connection env-1 changed identity to env-2 via server lifecycle welcome.", | |
| 180 | ); | |
| 181 | ||
| 182 | await connection.dispose(); | |
| 183 | }); | |
| 184 | ||
| 185 | it("rejects ensureBootstrapped when snapshot recovery fails", async () => { | |
| 186 | const environmentId = EnvironmentId.makeUnsafe("env-1"); | |
| 187 | const snapshotError = new Error("snapshot failed"); | |
| 188 | const { client } = createTestClient({ | |
| 189 | getSnapshot: async () => { | |
| 190 | throw snapshotError; | |
| 191 | }, | |
| 192 | }); | |
| 193 | ||
| 194 | const connection = createEnvironmentConnection({ | |
| 195 | kind: "saved", | |
| 196 | knownEnvironment: { | |
| 197 | id: "env-1", | |
| 198 | label: "Remote env", | |
| 199 | source: "manual", | |
| 200 | target: { | |
| 201 | httpBaseUrl: "http://example.test", | |
| 202 | wsBaseUrl: "ws://example.test", | |
| 203 | }, | |
| 204 | environmentId, | |
| 205 | }, | |
| 206 | client, | |
| 207 | applyEventBatch: vi.fn(), | |
| 208 | syncSnapshot: vi.fn(), | |
| 209 | applyTerminalEvent: vi.fn(), | |
| 210 | }); | |
| 211 | ||
| 212 | await expect(connection.ensureBootstrapped()).rejects.toThrow("snapshot failed"); | |
| 213 | ||
| 214 | await connection.dispose(); | |
| 215 | }); | |
| 216 | }); |
apps/web/src/environments/runtime/connection.ts created+296| ... | ... | @@ -0,0 +1,296 @@ |
| 1 | import type { | |
| 2 | EnvironmentId, | |
| 3 | OrchestrationEvent, | |
| 4 | OrchestrationReadModel, | |
| 5 | ServerConfig, | |
| 6 | ServerLifecycleWelcomePayload, | |
| 7 | TerminalEvent, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import type { KnownEnvironment } from "@t3tools/client-runtime"; | |
| 10 | ||
| 11 | import { | |
| 12 | deriveReplayRetryDecision, | |
| 13 | type OrchestrationRecoveryReason, | |
| 14 | } from "../../orchestrationRecovery"; | |
| 15 | import { | |
| 16 | createOrchestrationRecoveryCoordinator, | |
| 17 | type ReplayRetryTracker, | |
| 18 | } from "../../orchestrationRecovery"; | |
| 19 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | |
| 20 | ||
| 21 | const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; | |
| 22 | const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; | |
| 23 | ||
| 24 | export interface EnvironmentConnection { | |
| 25 | readonly kind: "primary" | "saved"; | |
| 26 | readonly environmentId: EnvironmentId; | |
| 27 | readonly knownEnvironment: KnownEnvironment; | |
| 28 | readonly client: WsRpcClient; | |
| 29 | readonly ensureBootstrapped: () => Promise<void>; | |
| 30 | readonly reconnect: () => Promise<void>; | |
| 31 | readonly dispose: () => Promise<void>; | |
| 32 | } | |
| 33 | ||
| 34 | interface OrchestrationHandlers { | |
| 35 | readonly applyEventBatch: ( | |
| 36 | events: ReadonlyArray<OrchestrationEvent>, | |
| 37 | environmentId: EnvironmentId, | |
| 38 | ) => void; | |
| 39 | readonly syncSnapshot: (snapshot: OrchestrationReadModel, environmentId: EnvironmentId) => void; | |
| 40 | readonly applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => void; | |
| 41 | } | |
| 42 | ||
| 43 | interface EnvironmentConnectionInput extends OrchestrationHandlers { | |
| 44 | readonly kind: "primary" | "saved"; | |
| 45 | readonly knownEnvironment: KnownEnvironment; | |
| 46 | readonly client: WsRpcClient; | |
| 47 | readonly refreshMetadata?: () => Promise<void>; | |
| 48 | readonly onConfigSnapshot?: (config: ServerConfig) => void; | |
| 49 | readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; | |
| 50 | } | |
| 51 | ||
| 52 | function createSnapshotBootstrapController(input: { | |
| 53 | readonly isBootstrapped: () => boolean; | |
| 54 | readonly runSnapshotRecovery: ( | |
| 55 | reason: Extract<OrchestrationRecoveryReason, "bootstrap" | "replay-failed">, | |
| 56 | ) => Promise<void>; | |
| 57 | }) { | |
| 58 | let inFlight: Promise<void> | null = null; | |
| 59 | ||
| 60 | return { | |
| 61 | ensureSnapshotRecovery( | |
| 62 | reason: Extract<OrchestrationRecoveryReason, "bootstrap" | "replay-failed">, | |
| 63 | ): Promise<void> { | |
| 64 | if (input.isBootstrapped()) { | |
| 65 | return Promise.resolve(); | |
| 66 | } | |
| 67 | ||
| 68 | if (inFlight !== null) { | |
| 69 | return inFlight; | |
| 70 | } | |
| 71 | ||
| 72 | const nextInFlight = input.runSnapshotRecovery(reason).finally(() => { | |
| 73 | if (inFlight === nextInFlight) { | |
| 74 | inFlight = null; | |
| 75 | } | |
| 76 | }); | |
| 77 | inFlight = nextInFlight; | |
| 78 | return inFlight; | |
| 79 | }, | |
| 80 | }; | |
| 81 | } | |
| 82 | ||
| 83 | export function createEnvironmentConnection( | |
| 84 | input: EnvironmentConnectionInput, | |
| 85 | ): EnvironmentConnection { | |
| 86 | const recovery = createOrchestrationRecoveryCoordinator(); | |
| 87 | let replayRetryTracker: ReplayRetryTracker | null = null; | |
| 88 | const pendingDomainEvents: OrchestrationEvent[] = []; | |
| 89 | let flushPendingDomainEventsScheduled = false; | |
| 90 | const environmentId = input.knownEnvironment.environmentId; | |
| 91 | ||
| 92 | if (!environmentId) { | |
| 93 | throw new Error( | |
| 94 | `Known environment ${input.knownEnvironment.label} is missing its environmentId.`, | |
| 95 | ); | |
| 96 | } | |
| 97 | ||
| 98 | let disposed = false; | |
| 99 | ||
| 100 | const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { | |
| 101 | if (environmentId !== nextEnvironmentId) { | |
| 102 | throw new Error( | |
| 103 | `Environment connection ${environmentId} changed identity to ${nextEnvironmentId} via ${source}.`, | |
| 104 | ); | |
| 105 | } | |
| 106 | }; | |
| 107 | ||
| 108 | const flushPendingDomainEvents = () => { | |
| 109 | flushPendingDomainEventsScheduled = false; | |
| 110 | if (disposed || pendingDomainEvents.length === 0) { | |
| 111 | return; | |
| 112 | } | |
| 113 | ||
| 114 | const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); | |
| 115 | const nextEvents = recovery.markEventBatchApplied(events); | |
| 116 | if (nextEvents.length === 0) { | |
| 117 | return; | |
| 118 | } | |
| 119 | input.applyEventBatch(nextEvents, environmentId); | |
| 120 | }; | |
| 121 | ||
| 122 | const schedulePendingDomainEventFlush = () => { | |
| 123 | if (flushPendingDomainEventsScheduled) { | |
| 124 | return; | |
| 125 | } | |
| 126 | ||
| 127 | flushPendingDomainEventsScheduled = true; | |
| 128 | queueMicrotask(flushPendingDomainEvents); | |
| 129 | }; | |
| 130 | ||
| 131 | const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => { | |
| 132 | if (!recovery.beginReplayRecovery(reason)) { | |
| 133 | return; | |
| 134 | } | |
| 135 | ||
| 136 | const fromSequenceExclusive = recovery.getState().latestSequence; | |
| 137 | try { | |
| 138 | const events = await input.client.orchestration.replayEvents({ fromSequenceExclusive }); | |
| 139 | if (!disposed) { | |
| 140 | const nextEvents = recovery.markEventBatchApplied(events); | |
| 141 | if (nextEvents.length > 0) { | |
| 142 | input.applyEventBatch(nextEvents, environmentId); | |
| 143 | } | |
| 144 | } | |
| 145 | } catch { | |
| 146 | replayRetryTracker = null; | |
| 147 | recovery.failReplayRecovery(); | |
| 148 | await snapshotBootstrap.ensureSnapshotRecovery("replay-failed"); | |
| 149 | return; | |
| 150 | } | |
| 151 | ||
| 152 | if (disposed) { | |
| 153 | return; | |
| 154 | } | |
| 155 | ||
| 156 | const replayCompletion = recovery.completeReplayRecovery(); | |
| 157 | const retryDecision = deriveReplayRetryDecision({ | |
| 158 | previousTracker: replayRetryTracker, | |
| 159 | completion: replayCompletion, | |
| 160 | recoveryState: recovery.getState(), | |
| 161 | baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, | |
| 162 | maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, | |
| 163 | }); | |
| 164 | replayRetryTracker = retryDecision.tracker; | |
| 165 | ||
| 166 | if (retryDecision.shouldRetry) { | |
| 167 | if (retryDecision.delayMs > 0) { | |
| 168 | await new Promise<void>((resolve) => { | |
| 169 | setTimeout(resolve, retryDecision.delayMs); | |
| 170 | }); | |
| 171 | if (disposed) { | |
| 172 | return; | |
| 173 | } | |
| 174 | } | |
| 175 | void runReplayRecovery(reason); | |
| 176 | } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { | |
| 177 | console.warn( | |
| 178 | "[orchestration-recovery]", | |
| 179 | "Stopping replay recovery after no-progress retries.", | |
| 180 | { | |
| 181 | environmentId, | |
| 182 | state: recovery.getState(), | |
| 183 | }, | |
| 184 | ); | |
| 185 | } | |
| 186 | }; | |
| 187 | ||
| 188 | const runSnapshotRecovery = async ( | |
| 189 | reason: Extract<OrchestrationRecoveryReason, "bootstrap" | "replay-failed">, | |
| 190 | ): Promise<void> => { | |
| 191 | const started = recovery.beginSnapshotRecovery(reason); | |
| 192 | if (!started) { | |
| 193 | return; | |
| 194 | } | |
| 195 | ||
| 196 | try { | |
| 197 | const snapshot = await input.client.orchestration.getSnapshot(); | |
| 198 | if (!disposed) { | |
| 199 | input.syncSnapshot(snapshot, environmentId); | |
| 200 | if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { | |
| 201 | void runReplayRecovery("sequence-gap"); | |
| 202 | } | |
| 203 | } | |
| 204 | } catch (error) { | |
| 205 | recovery.failSnapshotRecovery(); | |
| 206 | throw error; | |
| 207 | } | |
| 208 | }; | |
| 209 | ||
| 210 | const snapshotBootstrap = createSnapshotBootstrapController({ | |
| 211 | isBootstrapped: () => recovery.getState().bootstrapped, | |
| 212 | runSnapshotRecovery, | |
| 213 | }); | |
| 214 | ||
| 215 | const unsubLifecycle = input.client.server.subscribeLifecycle( | |
| 216 | (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => { | |
| 217 | if (event.type !== "welcome") { | |
| 218 | return; | |
| 219 | } | |
| 220 | observeEnvironmentIdentity( | |
| 221 | event.payload.environment.environmentId, | |
| 222 | "server lifecycle welcome", | |
| 223 | ); | |
| 224 | input.onWelcome?.(event.payload); | |
| 225 | }, | |
| 226 | ); | |
| 227 | ||
| 228 | const unsubConfig = input.client.server.subscribeConfig( | |
| 229 | (event: Parameters<Parameters<WsRpcClient["server"]["subscribeConfig"]>[0]>[0]) => { | |
| 230 | if (event.type !== "snapshot") { | |
| 231 | return; | |
| 232 | } | |
| 233 | observeEnvironmentIdentity(event.config.environment.environmentId, "server config snapshot"); | |
| 234 | input.onConfigSnapshot?.(event.config); | |
| 235 | }, | |
| 236 | ); | |
| 237 | ||
| 238 | const unsubDomainEvent = input.client.orchestration.onDomainEvent( | |
| 239 | (event: Parameters<Parameters<WsRpcClient["orchestration"]["onDomainEvent"]>[0]>[0]) => { | |
| 240 | const action = recovery.classifyDomainEvent(event.sequence); | |
| 241 | if (action === "apply") { | |
| 242 | pendingDomainEvents.push(event); | |
| 243 | schedulePendingDomainEventFlush(); | |
| 244 | return; | |
| 245 | } | |
| 246 | if (action === "recover") { | |
| 247 | flushPendingDomainEvents(); | |
| 248 | void runReplayRecovery("sequence-gap"); | |
| 249 | } | |
| 250 | }, | |
| 251 | { | |
| 252 | onResubscribe: () => { | |
| 253 | if (disposed) { | |
| 254 | return; | |
| 255 | } | |
| 256 | flushPendingDomainEvents(); | |
| 257 | void runReplayRecovery("resubscribe"); | |
| 258 | }, | |
| 259 | }, | |
| 260 | ); | |
| 261 | ||
| 262 | const unsubTerminalEvent = input.client.terminal.onEvent( | |
| 263 | (event: Parameters<Parameters<WsRpcClient["terminal"]["onEvent"]>[0]>[0]) => { | |
| 264 | input.applyTerminalEvent(event, environmentId); | |
| 265 | }, | |
| 266 | ); | |
| 267 | ||
| 268 | void snapshotBootstrap.ensureSnapshotRecovery("bootstrap").catch(() => undefined); | |
| 269 | ||
| 270 | const cleanup = () => { | |
| 271 | disposed = true; | |
| 272 | flushPendingDomainEventsScheduled = false; | |
| 273 | pendingDomainEvents.length = 0; | |
| 274 | unsubDomainEvent(); | |
| 275 | unsubTerminalEvent(); | |
| 276 | unsubLifecycle(); | |
| 277 | unsubConfig(); | |
| 278 | }; | |
| 279 | ||
| 280 | return { | |
| 281 | kind: input.kind, | |
| 282 | environmentId, | |
| 283 | knownEnvironment: input.knownEnvironment, | |
| 284 | client: input.client, | |
| 285 | ensureBootstrapped: () => snapshotBootstrap.ensureSnapshotRecovery("bootstrap"), | |
| 286 | reconnect: async () => { | |
| 287 | await input.client.reconnect(); | |
| 288 | await input.refreshMetadata?.(); | |
| 289 | await snapshotBootstrap.ensureSnapshotRecovery("bootstrap"); | |
| 290 | }, | |
| 291 | dispose: async () => { | |
| 292 | cleanup(); | |
| 293 | await input.client.dispose(); | |
| 294 | }, | |
| 295 | }; | |
| 296 | } |
apps/web/src/environments/runtime/index.ts created+29| ... | ... | @@ -0,0 +1,29 @@ |
| 1 | export { | |
| 2 | getEnvironmentHttpBaseUrl, | |
| 3 | getSavedEnvironmentRecord, | |
| 4 | getSavedEnvironmentRuntimeState, | |
| 5 | hasSavedEnvironmentRegistryHydrated, | |
| 6 | listSavedEnvironmentRecords, | |
| 7 | resetSavedEnvironmentRegistryStoreForTests, | |
| 8 | resetSavedEnvironmentRuntimeStoreForTests, | |
| 9 | resolveEnvironmentHttpUrl, | |
| 10 | useSavedEnvironmentRegistryStore, | |
| 11 | useSavedEnvironmentRuntimeStore, | |
| 12 | waitForSavedEnvironmentRegistryHydration, | |
| 13 | type SavedEnvironmentRecord, | |
| 14 | type SavedEnvironmentRuntimeState, | |
| 15 | } from "./catalog"; | |
| 16 | ||
| 17 | export { | |
| 18 | addSavedEnvironment, | |
| 19 | disconnectSavedEnvironment, | |
| 20 | ensureEnvironmentConnectionBootstrapped, | |
| 21 | getPrimaryEnvironmentConnection, | |
| 22 | readEnvironmentConnection, | |
| 23 | reconnectSavedEnvironment, | |
| 24 | removeSavedEnvironment, | |
| 25 | requireEnvironmentConnection, | |
| 26 | resetEnvironmentServiceForTests, | |
| 27 | startEnvironmentConnectionService, | |
| 28 | subscribeEnvironmentConnections, | |
| 29 | } from "./service"; |
apps/web/src/environments/runtime/service.ts created+659| ... | ... | @@ -0,0 +1,659 @@ |
| 1 | import { | |
| 2 | type AuthSessionRole, | |
| 3 | type EnvironmentId, | |
| 4 | type OrchestrationEvent, | |
| 5 | type OrchestrationReadModel, | |
| 6 | type ServerConfig, | |
| 7 | type TerminalEvent, | |
| 8 | ThreadId, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { type QueryClient } from "@tanstack/react-query"; | |
| 11 | import { Throttler } from "@tanstack/react-pacer"; | |
| 12 | import { | |
| 13 | createKnownEnvironment, | |
| 14 | getKnownEnvironmentWsBaseUrl, | |
| 15 | scopedProjectKey, | |
| 16 | scopedThreadKey, | |
| 17 | scopeProjectRef, | |
| 18 | scopeThreadRef, | |
| 19 | } from "@t3tools/client-runtime"; | |
| 20 | ||
| 21 | import { | |
| 22 | markPromotedDraftThreadByRef, | |
| 23 | markPromotedDraftThreadsByRef, | |
| 24 | useComposerDraftStore, | |
| 25 | } from "~/composerDraftStore"; | |
| 26 | import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup"; | |
| 27 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; | |
| 28 | import { projectQueryKeys } from "~/lib/projectReactQuery"; | |
| 29 | import { providerQueryKeys } from "~/lib/providerReactQuery"; | |
| 30 | import { getPrimaryKnownEnvironment } from "../primary"; | |
| 31 | import { | |
| 32 | bootstrapRemoteBearerSession, | |
| 33 | fetchRemoteEnvironmentDescriptor, | |
| 34 | fetchRemoteSessionState, | |
| 35 | resolveRemoteWebSocketConnectionUrl, | |
| 36 | } from "../remote/api"; | |
| 37 | import { resolveRemotePairingTarget } from "../remote/target"; | |
| 38 | import { | |
| 39 | getSavedEnvironmentRecord, | |
| 40 | hasSavedEnvironmentRegistryHydrated, | |
| 41 | listSavedEnvironmentRecords, | |
| 42 | type SavedEnvironmentRecord, | |
| 43 | useSavedEnvironmentRegistryStore, | |
| 44 | useSavedEnvironmentRuntimeStore, | |
| 45 | waitForSavedEnvironmentRegistryHydration, | |
| 46 | } from "./catalog"; | |
| 47 | import { createEnvironmentConnection, type EnvironmentConnection } from "./connection"; | |
| 48 | import { | |
| 49 | useStore, | |
| 50 | selectProjectsAcrossEnvironments, | |
| 51 | selectThreadByRef, | |
| 52 | selectThreadsAcrossEnvironments, | |
| 53 | } from "~/store"; | |
| 54 | import { useTerminalStateStore } from "~/terminalStateStore"; | |
| 55 | import { useUiStateStore } from "~/uiStateStore"; | |
| 56 | import { WsTransport } from "../../rpc/wsTransport"; | |
| 57 | import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; | |
| 58 | ||
| 59 | type EnvironmentServiceState = { | |
| 60 | readonly queryClient: QueryClient; | |
| 61 | readonly queryInvalidationThrottler: Throttler<() => void>; | |
| 62 | refCount: number; | |
| 63 | stop: () => void; | |
| 64 | }; | |
| 65 | ||
| 66 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); | |
| 67 | const environmentConnectionListeners = new Set<() => void>(); | |
| 68 | ||
| 69 | let activeService: EnvironmentServiceState | null = null; | |
| 70 | let needsProviderInvalidation = false; | |
| 71 | ||
| 72 | function emitEnvironmentConnectionRegistryChange() { | |
| 73 | for (const listener of environmentConnectionListeners) { | |
| 74 | listener(); | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | function getRuntimeErrorFields(error: unknown) { | |
| 79 | return { | |
| 80 | lastError: error instanceof Error ? error.message : String(error), | |
| 81 | lastErrorAt: new Date().toISOString(), | |
| 82 | } as const; | |
| 83 | } | |
| 84 | ||
| 85 | function isoNow(): string { | |
| 86 | return new Date().toISOString(); | |
| 87 | } | |
| 88 | ||
| 89 | function setRuntimeConnecting(environmentId: EnvironmentId) { | |
| 90 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | |
| 91 | connectionState: "connecting", | |
| 92 | lastError: null, | |
| 93 | lastErrorAt: null, | |
| 94 | }); | |
| 95 | } | |
| 96 | ||
| 97 | function setRuntimeConnected(environmentId: EnvironmentId) { | |
| 98 | const connectedAt = isoNow(); | |
| 99 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | |
| 100 | connectionState: "connected", | |
| 101 | authState: "authenticated", | |
| 102 | connectedAt, | |
| 103 | disconnectedAt: null, | |
| 104 | lastError: null, | |
| 105 | lastErrorAt: null, | |
| 106 | }); | |
| 107 | useSavedEnvironmentRegistryStore.getState().markConnected(environmentId, connectedAt); | |
| 108 | } | |
| 109 | ||
| 110 | function setRuntimeDisconnected(environmentId: EnvironmentId, reason?: string | null) { | |
| 111 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | |
| 112 | connectionState: "disconnected", | |
| 113 | disconnectedAt: isoNow(), | |
| 114 | ...(reason && reason.trim().length > 0 | |
| 115 | ? { | |
| 116 | lastError: reason, | |
| 117 | lastErrorAt: isoNow(), | |
| 118 | } | |
| 119 | : {}), | |
| 120 | }); | |
| 121 | } | |
| 122 | ||
| 123 | function setRuntimeError(environmentId: EnvironmentId, error: unknown) { | |
| 124 | useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { | |
| 125 | connectionState: "error", | |
| 126 | ...getRuntimeErrorFields(error), | |
| 127 | }); | |
| 128 | } | |
| 129 | ||
| 130 | function coalesceOrchestrationUiEvents( | |
| 131 | events: ReadonlyArray<OrchestrationEvent>, | |
| 132 | ): OrchestrationEvent[] { | |
| 133 | if (events.length < 2) { | |
| 134 | return [...events]; | |
| 135 | } | |
| 136 | ||
| 137 | const coalesced: OrchestrationEvent[] = []; | |
| 138 | for (const event of events) { | |
| 139 | const previous = coalesced.at(-1); | |
| 140 | if ( | |
| 141 | previous?.type === "thread.message-sent" && | |
| 142 | event.type === "thread.message-sent" && | |
| 143 | previous.payload.threadId === event.payload.threadId && | |
| 144 | previous.payload.messageId === event.payload.messageId | |
| 145 | ) { | |
| 146 | coalesced[coalesced.length - 1] = { | |
| 147 | ...event, | |
| 148 | payload: { | |
| 149 | ...event.payload, | |
| 150 | attachments: event.payload.attachments ?? previous.payload.attachments, | |
| 151 | createdAt: previous.payload.createdAt, | |
| 152 | text: | |
| 153 | !event.payload.streaming && event.payload.text.length > 0 | |
| 154 | ? event.payload.text | |
| 155 | : previous.payload.text + event.payload.text, | |
| 156 | }, | |
| 157 | }; | |
| 158 | continue; | |
| 159 | } | |
| 160 | ||
| 161 | coalesced.push(event); | |
| 162 | } | |
| 163 | ||
| 164 | return coalesced; | |
| 165 | } | |
| 166 | ||
| 167 | function reconcileSnapshotDerivedState() { | |
| 168 | const storeState = useStore.getState(); | |
| 169 | const threads = selectThreadsAcrossEnvironments(storeState); | |
| 170 | const projects = selectProjectsAcrossEnvironments(storeState); | |
| 171 | ||
| 172 | useUiStateStore.getState().syncProjects( | |
| 173 | projects.map((project) => ({ | |
| 174 | key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 175 | cwd: project.cwd, | |
| 176 | })), | |
| 177 | ); | |
| 178 | useUiStateStore.getState().syncThreads( | |
| 179 | threads.map((thread) => ({ | |
| 180 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 181 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, | |
| 182 | })), | |
| 183 | ); | |
| 184 | markPromotedDraftThreadsByRef( | |
| 185 | threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), | |
| 186 | ); | |
| 187 | ||
| 188 | const activeThreadKeys = collectActiveTerminalThreadIds({ | |
| 189 | snapshotThreads: threads.map((thread) => ({ | |
| 190 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 191 | deletedAt: null, | |
| 192 | archivedAt: thread.archivedAt, | |
| 193 | })), | |
| 194 | draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), | |
| 195 | }); | |
| 196 | useTerminalStateStore.getState().removeOrphanedTerminalStates(activeThreadKeys); | |
| 197 | } | |
| 198 | ||
| 199 | function applyRecoveredEventBatch( | |
| 200 | events: ReadonlyArray<OrchestrationEvent>, | |
| 201 | environmentId: EnvironmentId, | |
| 202 | ) { | |
| 203 | if (events.length === 0) { | |
| 204 | return; | |
| 205 | } | |
| 206 | ||
| 207 | const batchEffects = deriveOrchestrationBatchEffects(events); | |
| 208 | const uiEvents = coalesceOrchestrationUiEvents(events); | |
| 209 | const needsProjectUiSync = events.some( | |
| 210 | (event) => | |
| 211 | event.type === "project.created" || | |
| 212 | event.type === "project.meta-updated" || | |
| 213 | event.type === "project.deleted", | |
| 214 | ); | |
| 215 | ||
| 216 | if (batchEffects.needsProviderInvalidation) { | |
| 217 | needsProviderInvalidation = true; | |
| 218 | void activeService?.queryInvalidationThrottler.maybeExecute(); | |
| 219 | } | |
| 220 | ||
| 221 | useStore.getState().applyOrchestrationEvents(uiEvents, environmentId); | |
| 222 | if (needsProjectUiSync) { | |
| 223 | const projects = selectProjectsAcrossEnvironments(useStore.getState()); | |
| 224 | useUiStateStore.getState().syncProjects( | |
| 225 | projects.map((project) => ({ | |
| 226 | key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 227 | cwd: project.cwd, | |
| 228 | })), | |
| 229 | ); | |
| 230 | } | |
| 231 | ||
| 232 | const needsThreadUiSync = events.some( | |
| 233 | (event) => event.type === "thread.created" || event.type === "thread.deleted", | |
| 234 | ); | |
| 235 | if (needsThreadUiSync) { | |
| 236 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); | |
| 237 | useUiStateStore.getState().syncThreads( | |
| 238 | threads.map((thread) => ({ | |
| 239 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 240 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, | |
| 241 | })), | |
| 242 | ); | |
| 243 | } | |
| 244 | ||
| 245 | const draftStore = useComposerDraftStore.getState(); | |
| 246 | for (const threadId of batchEffects.promoteDraftThreadIds) { | |
| 247 | markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId)); | |
| 248 | } | |
| 249 | for (const threadId of batchEffects.clearDeletedThreadIds) { | |
| 250 | draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId)); | |
| 251 | useUiStateStore | |
| 252 | .getState() | |
| 253 | .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); | |
| 254 | } | |
| 255 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { | |
| 256 | useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId)); | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | function createEnvironmentConnectionHandlers() { | |
| 261 | return { | |
| 262 | applyEventBatch: applyRecoveredEventBatch, | |
| 263 | syncSnapshot: (snapshot: OrchestrationReadModel, environmentId: EnvironmentId) => { | |
| 264 | useStore.getState().syncServerReadModel(snapshot, environmentId); | |
| 265 | reconcileSnapshotDerivedState(); | |
| 266 | }, | |
| 267 | applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { | |
| 268 | const threadRef = scopeThreadRef(environmentId, ThreadId.makeUnsafe(event.threadId)); | |
| 269 | const thread = selectThreadByRef(useStore.getState(), threadRef); | |
| 270 | if (!thread || thread.archivedAt !== null) { | |
| 271 | return; | |
| 272 | } | |
| 273 | useTerminalStateStore.getState().applyTerminalEvent(threadRef, event); | |
| 274 | }, | |
| 275 | }; | |
| 276 | } | |
| 277 | ||
| 278 | function createPrimaryEnvironmentClient( | |
| 279 | knownEnvironment: ReturnType<typeof getPrimaryKnownEnvironment>, | |
| 280 | ) { | |
| 281 | const wsBaseUrl = getKnownEnvironmentWsBaseUrl(knownEnvironment); | |
| 282 | if (!wsBaseUrl) { | |
| 283 | throw new Error( | |
| 284 | `Unable to resolve websocket URL for ${knownEnvironment?.label ?? "primary environment"}.`, | |
| 285 | ); | |
| 286 | } | |
| 287 | ||
| 288 | return createWsRpcClient(new WsTransport(wsBaseUrl)); | |
| 289 | } | |
| 290 | ||
| 291 | function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClient { | |
| 292 | useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId); | |
| 293 | ||
| 294 | return createWsRpcClient( | |
| 295 | new WsTransport( | |
| 296 | () => | |
| 297 | resolveRemoteWebSocketConnectionUrl({ | |
| 298 | wsBaseUrl: record.wsBaseUrl, | |
| 299 | httpBaseUrl: record.httpBaseUrl, | |
| 300 | bearerToken: record.bearerToken, | |
| 301 | }), | |
| 302 | { | |
| 303 | onAttempt: () => { | |
| 304 | setRuntimeConnecting(record.environmentId); | |
| 305 | }, | |
| 306 | onOpen: () => { | |
| 307 | setRuntimeConnected(record.environmentId); | |
| 308 | }, | |
| 309 | onError: (message: string) => { | |
| 310 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | |
| 311 | connectionState: "error", | |
| 312 | lastError: message, | |
| 313 | lastErrorAt: isoNow(), | |
| 314 | }); | |
| 315 | }, | |
| 316 | onClose: (details: { readonly code: number; readonly reason: string }) => { | |
| 317 | setRuntimeDisconnected(record.environmentId, details.reason); | |
| 318 | }, | |
| 319 | }, | |
| 320 | ), | |
| 321 | ); | |
| 322 | } | |
| 323 | ||
| 324 | async function refreshSavedEnvironmentMetadata( | |
| 325 | record: SavedEnvironmentRecord, | |
| 326 | client: WsRpcClient, | |
| 327 | roleHint?: AuthSessionRole | null, | |
| 328 | configHint?: ServerConfig | null, | |
| 329 | ): Promise<void> { | |
| 330 | const [serverConfig, sessionState] = await Promise.all([ | |
| 331 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), | |
| 332 | fetchRemoteSessionState({ | |
| 333 | httpBaseUrl: record.httpBaseUrl, | |
| 334 | bearerToken: record.bearerToken, | |
| 335 | }), | |
| 336 | ]); | |
| 337 | ||
| 338 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | |
| 339 | authState: sessionState.authenticated ? "authenticated" : "requires-auth", | |
| 340 | descriptor: serverConfig.environment, | |
| 341 | serverConfig, | |
| 342 | role: sessionState.authenticated ? (sessionState.role ?? roleHint ?? null) : null, | |
| 343 | }); | |
| 344 | } | |
| 345 | ||
| 346 | function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { | |
| 347 | const existing = environmentConnections.get(connection.environmentId); | |
| 348 | if (existing && existing !== connection) { | |
| 349 | throw new Error(`Environment ${connection.environmentId} already has an active connection.`); | |
| 350 | } | |
| 351 | environmentConnections.set(connection.environmentId, connection); | |
| 352 | emitEnvironmentConnectionRegistryChange(); | |
| 353 | return connection; | |
| 354 | } | |
| 355 | ||
| 356 | async function removeConnection(environmentId: EnvironmentId): Promise<boolean> { | |
| 357 | const connection = environmentConnections.get(environmentId); | |
| 358 | if (!connection) { | |
| 359 | return false; | |
| 360 | } | |
| 361 | ||
| 362 | environmentConnections.delete(environmentId); | |
| 363 | emitEnvironmentConnectionRegistryChange(); | |
| 364 | await connection.dispose(); | |
| 365 | return true; | |
| 366 | } | |
| 367 | ||
| 368 | function createPrimaryEnvironmentConnection(): EnvironmentConnection { | |
| 369 | const knownEnvironment = getPrimaryKnownEnvironment(); | |
| 370 | if (!knownEnvironment?.environmentId) { | |
| 371 | throw new Error("Unable to resolve the primary environment."); | |
| 372 | } | |
| 373 | ||
| 374 | const existing = environmentConnections.get(knownEnvironment.environmentId); | |
| 375 | if (existing) { | |
| 376 | return existing; | |
| 377 | } | |
| 378 | ||
| 379 | return registerConnection( | |
| 380 | createEnvironmentConnection({ | |
| 381 | kind: "primary", | |
| 382 | knownEnvironment, | |
| 383 | client: createPrimaryEnvironmentClient(knownEnvironment), | |
| 384 | ...createEnvironmentConnectionHandlers(), | |
| 385 | }), | |
| 386 | ); | |
| 387 | } | |
| 388 | ||
| 389 | async function ensureSavedEnvironmentConnection( | |
| 390 | record: SavedEnvironmentRecord, | |
| 391 | options?: { | |
| 392 | readonly client?: WsRpcClient; | |
| 393 | readonly role?: AuthSessionRole | null; | |
| 394 | readonly serverConfig?: ServerConfig | null; | |
| 395 | }, | |
| 396 | ): Promise<EnvironmentConnection> { | |
| 397 | const existing = environmentConnections.get(record.environmentId); | |
| 398 | if (existing) { | |
| 399 | return existing; | |
| 400 | } | |
| 401 | ||
| 402 | const client = options?.client ?? createSavedEnvironmentClient(record); | |
| 403 | const knownEnvironment = createKnownEnvironment({ | |
| 404 | id: record.environmentId, | |
| 405 | label: record.label, | |
| 406 | source: "manual", | |
| 407 | target: { | |
| 408 | httpBaseUrl: record.httpBaseUrl, | |
| 409 | wsBaseUrl: record.wsBaseUrl, | |
| 410 | }, | |
| 411 | }); | |
| 412 | const connection = createEnvironmentConnection({ | |
| 413 | kind: "saved", | |
| 414 | knownEnvironment: { | |
| 415 | ...knownEnvironment, | |
| 416 | environmentId: record.environmentId, | |
| 417 | }, | |
| 418 | client, | |
| 419 | refreshMetadata: async () => { | |
| 420 | await refreshSavedEnvironmentMetadata(record, client); | |
| 421 | }, | |
| 422 | onConfigSnapshot: (config) => { | |
| 423 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | |
| 424 | descriptor: config.environment, | |
| 425 | serverConfig: config, | |
| 426 | }); | |
| 427 | }, | |
| 428 | onWelcome: (payload) => { | |
| 429 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | |
| 430 | descriptor: payload.environment, | |
| 431 | }); | |
| 432 | }, | |
| 433 | ...createEnvironmentConnectionHandlers(), | |
| 434 | }); | |
| 435 | ||
| 436 | registerConnection(connection); | |
| 437 | ||
| 438 | try { | |
| 439 | await refreshSavedEnvironmentMetadata( | |
| 440 | record, | |
| 441 | client, | |
| 442 | options?.role ?? null, | |
| 443 | options?.serverConfig ?? null, | |
| 444 | ); | |
| 445 | return connection; | |
| 446 | } catch (error) { | |
| 447 | setRuntimeError(record.environmentId, error); | |
| 448 | await removeConnection(record.environmentId).catch(() => false); | |
| 449 | throw error; | |
| 450 | } | |
| 451 | } | |
| 452 | ||
| 453 | async function syncSavedEnvironmentConnections( | |
| 454 | records: ReadonlyArray<SavedEnvironmentRecord>, | |
| 455 | ): Promise<void> { | |
| 456 | const expectedEnvironmentIds = new Set(records.map((record) => record.environmentId)); | |
| 457 | const staleEnvironmentIds = [...environmentConnections.values()] | |
| 458 | .filter((connection) => connection.kind === "saved") | |
| 459 | .map((connection) => connection.environmentId) | |
| 460 | .filter((environmentId) => !expectedEnvironmentIds.has(environmentId)); | |
| 461 | ||
| 462 | await Promise.all( | |
| 463 | staleEnvironmentIds.map((environmentId) => disconnectSavedEnvironment(environmentId)), | |
| 464 | ); | |
| 465 | await Promise.all( | |
| 466 | records.map((record) => ensureSavedEnvironmentConnection(record).catch(() => undefined)), | |
| 467 | ); | |
| 468 | } | |
| 469 | ||
| 470 | function stopActiveService() { | |
| 471 | activeService?.stop(); | |
| 472 | activeService = null; | |
| 473 | } | |
| 474 | ||
| 475 | export function subscribeEnvironmentConnections(listener: () => void): () => void { | |
| 476 | environmentConnectionListeners.add(listener); | |
| 477 | return () => { | |
| 478 | environmentConnectionListeners.delete(listener); | |
| 479 | }; | |
| 480 | } | |
| 481 | ||
| 482 | export function listEnvironmentConnections(): ReadonlyArray<EnvironmentConnection> { | |
| 483 | return [...environmentConnections.values()]; | |
| 484 | } | |
| 485 | ||
| 486 | export function readEnvironmentConnection( | |
| 487 | environmentId: EnvironmentId, | |
| 488 | ): EnvironmentConnection | null { | |
| 489 | return environmentConnections.get(environmentId) ?? null; | |
| 490 | } | |
| 491 | ||
| 492 | export function requireEnvironmentConnection(environmentId: EnvironmentId): EnvironmentConnection { | |
| 493 | const connection = readEnvironmentConnection(environmentId); | |
| 494 | if (!connection) { | |
| 495 | throw new Error(`No websocket client registered for environment ${environmentId}.`); | |
| 496 | } | |
| 497 | return connection; | |
| 498 | } | |
| 499 | ||
| 500 | export function getPrimaryEnvironmentConnection(): EnvironmentConnection { | |
| 501 | return createPrimaryEnvironmentConnection(); | |
| 502 | } | |
| 503 | ||
| 504 | export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> { | |
| 505 | const connection = environmentConnections.get(environmentId); | |
| 506 | if (connection?.kind !== "saved") { | |
| 507 | return; | |
| 508 | } | |
| 509 | ||
| 510 | useSavedEnvironmentRuntimeStore.getState().clear(environmentId); | |
| 511 | await removeConnection(environmentId).catch(() => false); | |
| 512 | } | |
| 513 | ||
| 514 | export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise<void> { | |
| 515 | const record = getSavedEnvironmentRecord(environmentId); | |
| 516 | if (!record) { | |
| 517 | throw new Error("Saved environment not found."); | |
| 518 | } | |
| 519 | ||
| 520 | const connection = environmentConnections.get(environmentId); | |
| 521 | if (!connection) { | |
| 522 | await ensureSavedEnvironmentConnection(record); | |
| 523 | return; | |
| 524 | } | |
| 525 | ||
| 526 | setRuntimeConnecting(environmentId); | |
| 527 | try { | |
| 528 | await connection.reconnect(); | |
| 529 | } catch (error) { | |
| 530 | setRuntimeError(environmentId, error); | |
| 531 | throw error; | |
| 532 | } | |
| 533 | } | |
| 534 | ||
| 535 | export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> { | |
| 536 | useSavedEnvironmentRegistryStore.getState().remove(environmentId); | |
| 537 | await disconnectSavedEnvironment(environmentId); | |
| 538 | } | |
| 539 | ||
| 540 | export async function addSavedEnvironment(input: { | |
| 541 | readonly label: string; | |
| 542 | readonly pairingUrl?: string; | |
| 543 | readonly host?: string; | |
| 544 | readonly pairingCode?: string; | |
| 545 | }): Promise<SavedEnvironmentRecord> { | |
| 546 | const resolvedTarget = resolveRemotePairingTarget({ | |
| 547 | ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}), | |
| 548 | ...(input.host !== undefined ? { host: input.host } : {}), | |
| 549 | ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}), | |
| 550 | }); | |
| 551 | const descriptor = await fetchRemoteEnvironmentDescriptor({ | |
| 552 | httpBaseUrl: resolvedTarget.httpBaseUrl, | |
| 553 | }); | |
| 554 | const environmentId = descriptor.environmentId; | |
| 555 | ||
| 556 | if (environmentConnections.has(environmentId)) { | |
| 557 | throw new Error("This environment is already connected."); | |
| 558 | } | |
| 559 | ||
| 560 | const bearerSession = await bootstrapRemoteBearerSession({ | |
| 561 | httpBaseUrl: resolvedTarget.httpBaseUrl, | |
| 562 | credential: resolvedTarget.credential, | |
| 563 | }); | |
| 564 | ||
| 565 | const record: SavedEnvironmentRecord = { | |
| 566 | environmentId, | |
| 567 | label: input.label.trim() || descriptor.label, | |
| 568 | wsBaseUrl: resolvedTarget.wsBaseUrl, | |
| 569 | httpBaseUrl: resolvedTarget.httpBaseUrl, | |
| 570 | bearerToken: bearerSession.sessionToken, | |
| 571 | createdAt: isoNow(), | |
| 572 | lastConnectedAt: isoNow(), | |
| 573 | }; | |
| 574 | ||
| 575 | await ensureSavedEnvironmentConnection(record, { | |
| 576 | role: bearerSession.role, | |
| 577 | }); | |
| 578 | useSavedEnvironmentRegistryStore.getState().upsert(record); | |
| 579 | return record; | |
| 580 | } | |
| 581 | ||
| 582 | export async function ensureEnvironmentConnectionBootstrapped( | |
| 583 | environmentId: EnvironmentId, | |
| 584 | ): Promise<void> { | |
| 585 | await environmentConnections.get(environmentId)?.ensureBootstrapped(); | |
| 586 | } | |
| 587 | ||
| 588 | export function startEnvironmentConnectionService(queryClient: QueryClient): () => void { | |
| 589 | if (activeService?.queryClient === queryClient) { | |
| 590 | activeService.refCount += 1; | |
| 591 | return () => { | |
| 592 | if (!activeService || activeService.queryClient !== queryClient) { | |
| 593 | return; | |
| 594 | } | |
| 595 | activeService.refCount -= 1; | |
| 596 | if (activeService.refCount === 0) { | |
| 597 | stopActiveService(); | |
| 598 | } | |
| 599 | }; | |
| 600 | } | |
| 601 | ||
| 602 | stopActiveService(); | |
| 603 | needsProviderInvalidation = false; | |
| 604 | const queryInvalidationThrottler = new Throttler( | |
| 605 | () => { | |
| 606 | if (!needsProviderInvalidation) { | |
| 607 | return; | |
| 608 | } | |
| 609 | needsProviderInvalidation = false; | |
| 610 | void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); | |
| 611 | void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all }); | |
| 612 | }, | |
| 613 | { | |
| 614 | wait: 100, | |
| 615 | leading: false, | |
| 616 | trailing: true, | |
| 617 | }, | |
| 618 | ); | |
| 619 | ||
| 620 | createPrimaryEnvironmentConnection(); | |
| 621 | ||
| 622 | const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => { | |
| 623 | if (!hasSavedEnvironmentRegistryHydrated()) { | |
| 624 | return; | |
| 625 | } | |
| 626 | void syncSavedEnvironmentConnections(listSavedEnvironmentRecords()); | |
| 627 | }); | |
| 628 | ||
| 629 | void waitForSavedEnvironmentRegistryHydration() | |
| 630 | .then(() => syncSavedEnvironmentConnections(listSavedEnvironmentRecords())) | |
| 631 | .catch(() => undefined); | |
| 632 | ||
| 633 | activeService = { | |
| 634 | queryClient, | |
| 635 | queryInvalidationThrottler, | |
| 636 | refCount: 1, | |
| 637 | stop: () => { | |
| 638 | unsubscribeSavedEnvironments(); | |
| 639 | queryInvalidationThrottler.cancel(); | |
| 640 | }, | |
| 641 | }; | |
| 642 | ||
| 643 | return () => { | |
| 644 | if (!activeService || activeService.queryClient !== queryClient) { | |
| 645 | return; | |
| 646 | } | |
| 647 | activeService.refCount -= 1; | |
| 648 | if (activeService.refCount === 0) { | |
| 649 | stopActiveService(); | |
| 650 | } | |
| 651 | }; | |
| 652 | } | |
| 653 | ||
| 654 | export async function resetEnvironmentServiceForTests(): Promise<void> { | |
| 655 | stopActiveService(); | |
| 656 | await Promise.all( | |
| 657 | [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), | |
| 658 | ); | |
| 659 | } |
apps/web/src/hooks/useTheme.ts+53| ... | ... | @@ -8,6 +8,8 @@ type ThemeSnapshot = { |
| 8 | 8 | |
| 9 | 9 | const STORAGE_KEY = "t3code:theme"; |
| 10 | 10 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 11 | const THEME_COLOR_META_NAME = "theme-color"; | |
| 12 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; | |
| 11 | 13 | |
| 12 | 14 | let listeners: Array<() => void> = []; |
| 13 | 15 | let lastSnapshot: ThemeSnapshot | null = null; |
| ... | ... | @@ -26,12 +28,63 @@ function getStored(): Theme { |
| 26 | 28 | return "system"; |
| 27 | 29 | } |
| 28 | 30 | |
| 31 | function ensureThemeColorMetaTag(): HTMLMetaElement { | |
| 32 | let element = document.querySelector<HTMLMetaElement>(DYNAMIC_THEME_COLOR_SELECTOR); | |
| 33 | if (element) { | |
| 34 | return element; | |
| 35 | } | |
| 36 | ||
| 37 | element = document.createElement("meta"); | |
| 38 | element.name = THEME_COLOR_META_NAME; | |
| 39 | element.setAttribute("data-dynamic-theme-color", "true"); | |
| 40 | document.head.append(element); | |
| 41 | return element; | |
| 42 | } | |
| 43 | ||
| 44 | function normalizeThemeColor(value: string | null | undefined): string | null { | |
| 45 | const normalizedValue = value?.trim().toLowerCase(); | |
| 46 | if ( | |
| 47 | !normalizedValue || | |
| 48 | normalizedValue === "transparent" || | |
| 49 | normalizedValue === "rgba(0, 0, 0, 0)" || | |
| 50 | normalizedValue === "rgba(0 0 0 / 0)" | |
| 51 | ) { | |
| 52 | return null; | |
| 53 | } | |
| 54 | ||
| 55 | return value?.trim() ?? null; | |
| 56 | } | |
| 57 | ||
| 58 | function resolveBrowserChromeSurface(): HTMLElement { | |
| 59 | return ( | |
| 60 | document.querySelector<HTMLElement>("main[data-slot='sidebar-inset']") ?? | |
| 61 | document.querySelector<HTMLElement>("[data-slot='sidebar-inner']") ?? | |
| 62 | document.body | |
| 63 | ); | |
| 64 | } | |
| 65 | ||
| 66 | export function syncBrowserChromeTheme() { | |
| 67 | if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; | |
| 68 | const surfaceColor = normalizeThemeColor( | |
| 69 | getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, | |
| 70 | ); | |
| 71 | const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); | |
| 72 | const backgroundColor = surfaceColor ?? fallbackColor; | |
| 73 | if (!backgroundColor) return; | |
| 74 | ||
| 75 | document.documentElement.style.backgroundColor = backgroundColor; | |
| 76 | document.body.style.backgroundColor = backgroundColor; | |
| 77 | ensureThemeColorMetaTag().setAttribute("content", backgroundColor); | |
| 78 | } | |
| 79 | ||
| 29 | 80 | function applyTheme(theme: Theme, suppressTransitions = false) { |
| 81 | if (typeof document === "undefined") return; | |
| 30 | 82 | if (suppressTransitions) { |
| 31 | 83 | document.documentElement.classList.add("no-transitions"); |
| 32 | 84 | } |
| 33 | 85 | const isDark = theme === "dark" || (theme === "system" && getSystemDark()); |
| 34 | 86 | document.documentElement.classList.toggle("dark", isDark); |
| 87 | syncBrowserChromeTheme(); | |
| 35 | 88 | syncDesktopTheme(theme); |
| 36 | 89 | if (suppressTransitions) { |
| 37 | 90 | // Force a reflow so the no-transitions class takes effect before removal |
apps/web/src/index.css+8-4| ... | ... | @@ -47,8 +47,12 @@ |
| 47 | 47 | * { |
| 48 | 48 | @apply border-border outline-ring/50; |
| 49 | 49 | } |
| 50 | html { | |
| 51 | background-color: var(--app-chrome-background); | |
| 52 | } | |
| 50 | 53 | body { |
| 51 | @apply bg-background text-foreground relative; | |
| 54 | @apply text-foreground relative; | |
| 55 | background-color: var(--app-chrome-background); | |
| 52 | 56 | } |
| 53 | 57 | } |
| 54 | 58 | |
| ... | ... | @@ -65,6 +69,7 @@ |
| 65 | 69 | color-scheme: light; |
| 66 | 70 | --radius: 0.625rem; |
| 67 | 71 | --background: var(--color-white); |
| 72 | --app-chrome-background: var(--background); | |
| 68 | 73 | --foreground: var(--color-neutral-800); |
| 69 | 74 | --card: var(--color-white); |
| 70 | 75 | --card-foreground: var(--color-neutral-800); |
| ... | ... | @@ -93,6 +98,7 @@ |
| 93 | 98 | @variant dark { |
| 94 | 99 | color-scheme: dark; |
| 95 | 100 | --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white)); |
| 101 | --app-chrome-background: var(--background); | |
| 96 | 102 | --foreground: var(--color-neutral-100); |
| 97 | 103 | --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); |
| 98 | 104 | --card-foreground: var(--color-neutral-100); |
| ... | ... | @@ -157,9 +163,7 @@ body::after { |
| 157 | 163 | } |
| 158 | 164 | |
| 159 | 165 | pre, |
| 160 | code, | |
| 161 | textarea, | |
| 162 | input { | |
| 166 | code { | |
| 163 | 167 | font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; |
| 164 | 168 | } |
| 165 | 169 |
apps/web/src/lib/gitReactQuery.ts+2-2| ... | ... | @@ -11,7 +11,7 @@ import { |
| 11 | 11 | type QueryClient, |
| 12 | 12 | } from "@tanstack/react-query"; |
| 13 | 13 | import { ensureEnvironmentApi } from "../environmentApi"; |
| 14 | import { getWsRpcClientForEnvironment } from "../wsRpcClient"; | |
| 14 | import { requireEnvironmentConnection } from "../environments/runtime"; | |
| 15 | 15 | |
| 16 | 16 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; |
| 17 | 17 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; |
| ... | ... | @@ -180,7 +180,7 @@ export function gitRunStackedActionMutationOptions(input: { |
| 180 | 180 | onProgress?: (event: GitActionProgressEvent) => void; |
| 181 | 181 | }) => { |
| 182 | 182 | if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable."); |
| 183 | return getWsRpcClientForEnvironment(input.environmentId).git.runStackedAction( | |
| 183 | return requireEnvironmentConnection(input.environmentId).client.git.runStackedAction( | |
| 184 | 184 | { |
| 185 | 185 | action, |
| 186 | 186 | actionId, |
apps/web/src/lib/gitStatusState.test.ts+177-1| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts"; |
| 2 | 2 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 3 | 3 | |
| 4 | import type { WsRpcClient } from "../rpc/wsRpcClient"; | |
| 5 | import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry"; | |
| 4 | 6 | import { |
| 5 | 7 | getGitStatusSnapshot, |
| 6 | 8 | resetGitStatusStateForTests, |
| ... | ... | @@ -8,6 +10,22 @@ import { |
| 8 | 10 | watchGitStatus, |
| 9 | 11 | } from "./gitStatusState"; |
| 10 | 12 | |
| 13 | const serviceHarness = vi.hoisted(() => ({ | |
| 14 | connections: new Map<string, any>(), | |
| 15 | listeners: new Set<() => void>(), | |
| 16 | })); | |
| 17 | ||
| 18 | vi.mock("../environments/runtime/service", () => ({ | |
| 19 | readEnvironmentConnection: (environmentId: string) => | |
| 20 | serviceHarness.connections.get(environmentId) ?? null, | |
| 21 | subscribeEnvironmentConnections: (listener: () => void) => { | |
| 22 | serviceHarness.listeners.add(listener); | |
| 23 | return () => { | |
| 24 | serviceHarness.listeners.delete(listener); | |
| 25 | }; | |
| 26 | }, | |
| 27 | })); | |
| 28 | ||
| 11 | 29 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { |
| 12 | 30 | listeners.add(listener); |
| 13 | 31 | return () => { |
| ... | ... | @@ -50,11 +68,110 @@ function emitGitStatus(event: GitStatusResult) { |
| 50 | 68 | } |
| 51 | 69 | } |
| 52 | 70 | |
| 53 | afterEach(() => { | |
| 71 | function createRegisteredGitStatusClient(environmentId: EnvironmentId) { | |
| 72 | const listeners = new Set<(event: GitStatusResult) => void>(); | |
| 73 | const client = { | |
| 74 | dispose: vi.fn(async () => undefined), | |
| 75 | reconnect: vi.fn(async () => undefined), | |
| 76 | terminal: { | |
| 77 | open: vi.fn(async () => undefined), | |
| 78 | write: vi.fn(async () => undefined), | |
| 79 | resize: vi.fn(async () => undefined), | |
| 80 | clear: vi.fn(async () => undefined), | |
| 81 | restart: vi.fn(async () => undefined), | |
| 82 | close: vi.fn(async () => undefined), | |
| 83 | onEvent: vi.fn(() => () => undefined), | |
| 84 | }, | |
| 85 | projects: { | |
| 86 | searchEntries: vi.fn(async () => []), | |
| 87 | writeFile: vi.fn(async () => undefined), | |
| 88 | }, | |
| 89 | shell: { | |
| 90 | openInEditor: vi.fn(async () => undefined), | |
| 91 | }, | |
| 92 | git: { | |
| 93 | pull: vi.fn(async () => undefined), | |
| 94 | refreshStatus: vi.fn(async (input: { cwd: string }) => ({ | |
| 95 | ...BASE_STATUS, | |
| 96 | branch: `${input.cwd}-refreshed`, | |
| 97 | })), | |
| 98 | onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => | |
| 99 | registerListener(listeners, listener), | |
| 100 | ), | |
| 101 | runStackedAction: vi.fn(async () => ({}) as any), | |
| 102 | listBranches: vi.fn(async () => []), | |
| 103 | createWorktree: vi.fn(async () => undefined), | |
| 104 | removeWorktree: vi.fn(async () => undefined), | |
| 105 | createBranch: vi.fn(async () => undefined), | |
| 106 | checkout: vi.fn(async () => undefined), | |
| 107 | init: vi.fn(async () => undefined), | |
| 108 | resolvePullRequest: vi.fn(async () => undefined), | |
| 109 | preparePullRequestThread: vi.fn(async () => undefined), | |
| 110 | }, | |
| 111 | server: { | |
| 112 | getConfig: vi.fn(async () => ({ | |
| 113 | environment: { | |
| 114 | environmentId, | |
| 115 | }, | |
| 116 | })), | |
| 117 | refreshProviders: vi.fn(async () => undefined), | |
| 118 | upsertKeybinding: vi.fn(async () => undefined), | |
| 119 | getSettings: vi.fn(async () => undefined), | |
| 120 | updateSettings: vi.fn(async () => undefined), | |
| 121 | subscribeConfig: vi.fn(() => () => undefined), | |
| 122 | subscribeLifecycle: vi.fn(() => () => undefined), | |
| 123 | subscribeAuthAccess: vi.fn(() => () => undefined), | |
| 124 | }, | |
| 125 | orchestration: { | |
| 126 | getSnapshot: vi.fn(async () => ({ snapshotSequence: 1, projects: [], threads: [] }) as any), | |
| 127 | dispatchCommand: vi.fn(async () => undefined), | |
| 128 | getTurnDiff: vi.fn(async () => undefined), | |
| 129 | getFullThreadDiff: vi.fn(async () => undefined), | |
| 130 | replayEvents: vi.fn(async () => []), | |
| 131 | onDomainEvent: vi.fn(() => () => undefined), | |
| 132 | }, | |
| 133 | } as unknown as WsRpcClient; | |
| 134 | ||
| 135 | serviceHarness.connections.set(environmentId, { | |
| 136 | kind: "saved" as const, | |
| 137 | knownEnvironment: { | |
| 138 | id: environmentId, | |
| 139 | label: `Environment ${environmentId}`, | |
| 140 | source: "manual" as const, | |
| 141 | environmentId, | |
| 142 | target: { | |
| 143 | httpBaseUrl: "http://example.test", | |
| 144 | wsBaseUrl: "ws://example.test", | |
| 145 | }, | |
| 146 | }, | |
| 147 | client, | |
| 148 | environmentId, | |
| 149 | ensureBootstrapped: async () => undefined, | |
| 150 | reconnect: async () => undefined, | |
| 151 | dispose: async () => undefined, | |
| 152 | }); | |
| 153 | for (const listener of serviceHarness.listeners) { | |
| 154 | listener(); | |
| 155 | } | |
| 156 | ||
| 157 | return { | |
| 158 | client, | |
| 159 | emit: (event: GitStatusResult) => { | |
| 160 | for (const listener of listeners) { | |
| 161 | listener(event); | |
| 162 | } | |
| 163 | }, | |
| 164 | }; | |
| 165 | } | |
| 166 | ||
| 167 | afterEach(async () => { | |
| 54 | 168 | gitStatusListeners.clear(); |
| 169 | serviceHarness.connections.clear(); | |
| 170 | serviceHarness.listeners.clear(); | |
| 55 | 171 | gitClient.onStatus.mockClear(); |
| 56 | 172 | gitClient.refreshStatus.mockClear(); |
| 57 | 173 | resetGitStatusStateForTests(); |
| 174 | resetAppAtomRegistryForTests(); | |
| 58 | 175 | }); |
| 59 | 176 | |
| 60 | 177 | describe("gitStatusState", () => { |
| ... | ... | @@ -147,4 +264,63 @@ describe("gitStatusState", () => { |
| 147 | 264 | releaseLocal(); |
| 148 | 265 | releaseRemote(); |
| 149 | 266 | }); |
| 267 | ||
| 268 | it("waits for a delayed environment client registration instead of throwing", () => { | |
| 269 | const release = watchGitStatus(TARGET); | |
| 270 | ||
| 271 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | |
| 272 | data: null, | |
| 273 | error: null, | |
| 274 | cause: null, | |
| 275 | isPending: true, | |
| 276 | }); | |
| 277 | ||
| 278 | const registered = createRegisteredGitStatusClient(ENVIRONMENT_ID); | |
| 279 | registered.emit(BASE_STATUS); | |
| 280 | ||
| 281 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | |
| 282 | data: BASE_STATUS, | |
| 283 | error: null, | |
| 284 | cause: null, | |
| 285 | isPending: false, | |
| 286 | }); | |
| 287 | ||
| 288 | release(); | |
| 289 | }); | |
| 290 | ||
| 291 | it("resubscribes after the environment client is removed and re-registered", async () => { | |
| 292 | const firstClient = createRegisteredGitStatusClient(ENVIRONMENT_ID); | |
| 293 | const release = watchGitStatus(TARGET); | |
| 294 | ||
| 295 | firstClient.emit(BASE_STATUS); | |
| 296 | expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status"); | |
| 297 | ||
| 298 | serviceHarness.connections.delete(ENVIRONMENT_ID); | |
| 299 | for (const listener of serviceHarness.listeners) { | |
| 300 | listener(); | |
| 301 | } | |
| 302 | ||
| 303 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | |
| 304 | data: BASE_STATUS, | |
| 305 | error: null, | |
| 306 | cause: null, | |
| 307 | isPending: true, | |
| 308 | }); | |
| 309 | ||
| 310 | const secondClient = createRegisteredGitStatusClient(ENVIRONMENT_ID); | |
| 311 | secondClient.emit({ ...BASE_STATUS, branch: "reconnected-branch" }); | |
| 312 | ||
| 313 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | |
| 314 | data: { ...BASE_STATUS, branch: "reconnected-branch" }, | |
| 315 | error: null, | |
| 316 | cause: null, | |
| 317 | isPending: false, | |
| 318 | }); | |
| 319 | ||
| 320 | release(); | |
| 321 | }); | |
| 322 | ||
| 323 | it("returns the cached snapshot when refresh is requested before the client is registered", async () => { | |
| 324 | await expect(refreshGitStatus(TARGET)).resolves.toBeNull(); | |
| 325 | }); | |
| 150 | 326 | }); |
apps/web/src/lib/gitStatusState.ts+83-21| ... | ... | @@ -9,25 +9,31 @@ import { Atom } from "effect/unstable/reactivity"; |
| 9 | 9 | import { useEffect } from "react"; |
| 10 | 10 | |
| 11 | 11 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 12 | import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient"; | |
| 13 | ||
| 14 | export type GitStatusStreamError = GitManagerServiceError; | |
| 12 | import { | |
| 13 | readEnvironmentConnection, | |
| 14 | subscribeEnvironmentConnections, | |
| 15 | } from "../environments/runtime"; | |
| 16 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | |
| 15 | 17 | |
| 16 | export interface GitStatusState { | |
| 18 | interface GitStatusState { | |
| 17 | 19 | readonly data: GitStatusResult | null; |
| 18 | readonly error: GitStatusStreamError | null; | |
| 19 | readonly cause: Cause.Cause<GitStatusStreamError> | null; | |
| 20 | readonly error: GitManagerServiceError | null; | |
| 21 | readonly cause: Cause.Cause<GitManagerServiceError> | null; | |
| 20 | 22 | readonly isPending: boolean; |
| 21 | 23 | } |
| 22 | 24 | |
| 23 | 25 | type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">; |
| 26 | interface ResolvedGitStatusClient { | |
| 27 | readonly clientIdentity: string; | |
| 28 | readonly client: GitStatusClient; | |
| 29 | } | |
| 24 | 30 | |
| 25 | 31 | interface WatchedGitStatus { |
| 26 | 32 | refCount: number; |
| 27 | 33 | unsubscribe: () => void; |
| 28 | 34 | } |
| 29 | 35 | |
| 30 | export interface GitStatusTarget { | |
| 36 | interface GitStatusTarget { | |
| 31 | 37 | readonly environmentId: EnvironmentId | null; |
| 32 | 38 | readonly cwd: string | null; |
| 33 | 39 | } |
| ... | ... | @@ -64,18 +70,21 @@ const gitStatusStateAtom = Atom.family((key: string) => { |
| 64 | 70 | }); |
| 65 | 71 | |
| 66 | 72 | function getGitStatusTargetKey(target: GitStatusTarget): string | null { |
| 67 | if (target.cwd === null) { | |
| 73 | if (target.environmentId === null || target.cwd === null) { | |
| 68 | 74 | return null; |
| 69 | 75 | } |
| 70 | 76 | |
| 71 | return `${target.environmentId ?? "__default__"}:${target.cwd}`; | |
| 77 | return `${target.environmentId}:${target.cwd}`; | |
| 72 | 78 | } |
| 73 | 79 | |
| 74 | function resolveGitStatusClient(target: GitStatusTarget): GitStatusClient { | |
| 75 | if (target.environmentId) { | |
| 76 | return getWsRpcClientForEnvironment(target.environmentId).git; | |
| 80 | function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatusClient | null { | |
| 81 | if (target.environmentId === null) { | |
| 82 | return null; | |
| 77 | 83 | } |
| 78 | return getWsRpcClient().git; | |
| 84 | const connection = readEnvironmentConnection(target.environmentId); | |
| 85 | return connection | |
| 86 | ? { clientIdentity: connection.environmentId, client: connection.client.git } | |
| 87 | : null; | |
| 79 | 88 | } |
| 80 | 89 | |
| 81 | 90 | export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState { |
| ... | ... | @@ -87,10 +96,7 @@ export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState { |
| 87 | 96 | return appAtomRegistry.get(gitStatusStateAtom(targetKey)); |
| 88 | 97 | } |
| 89 | 98 | |
| 90 | export function watchGitStatus( | |
| 91 | target: GitStatusTarget, | |
| 92 | client: GitStatusClient = resolveGitStatusClient(target), | |
| 93 | ): () => void { | |
| 99 | export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient): () => void { | |
| 94 | 100 | const targetKey = getGitStatusTargetKey(target); |
| 95 | 101 | if (targetKey === null) { |
| 96 | 102 | return NOOP; |
| ... | ... | @@ -104,7 +110,7 @@ export function watchGitStatus( |
| 104 | 110 | |
| 105 | 111 | watchedGitStatuses.set(targetKey, { |
| 106 | 112 | refCount: 1, |
| 107 | unsubscribe: subscribeToGitStatus(targetKey, target.cwd!, client), | |
| 113 | unsubscribe: subscribeToGitStatusTarget(targetKey, target, client), | |
| 108 | 114 | }); |
| 109 | 115 | |
| 110 | 116 | return () => unwatchGitStatus(targetKey); |
| ... | ... | @@ -112,13 +118,18 @@ export function watchGitStatus( |
| 112 | 118 | |
| 113 | 119 | export function refreshGitStatus( |
| 114 | 120 | target: GitStatusTarget, |
| 115 | client: GitStatusClient = resolveGitStatusClient(target), | |
| 121 | client?: GitStatusClient, | |
| 116 | 122 | ): Promise<GitStatusResult | null> { |
| 117 | 123 | const targetKey = getGitStatusTargetKey(target); |
| 118 | 124 | if (targetKey === null || target.cwd === null) { |
| 119 | 125 | return Promise.resolve(null); |
| 120 | 126 | } |
| 121 | 127 | |
| 128 | const resolvedClient = client ?? readResolvedGitStatusClient(target)?.client; | |
| 129 | if (!resolvedClient) { | |
| 130 | return Promise.resolve(getGitStatusSnapshot(target).data); | |
| 131 | } | |
| 132 | ||
| 122 | 133 | const currentInFlight = gitStatusRefreshInFlight.get(targetKey); |
| 123 | 134 | if (currentInFlight) { |
| 124 | 135 | return currentInFlight; |
| ... | ... | @@ -130,7 +141,7 @@ export function refreshGitStatus( |
| 130 | 141 | } |
| 131 | 142 | |
| 132 | 143 | gitStatusLastRefreshAtByKey.set(targetKey, Date.now()); |
| 133 | const refreshPromise = client.refreshStatus({ cwd: target.cwd }).finally(() => { | |
| 144 | const refreshPromise = resolvedClient.refreshStatus({ cwd: target.cwd }).finally(() => { | |
| 134 | 145 | gitStatusRefreshInFlight.delete(targetKey); |
| 135 | 146 | }); |
| 136 | 147 | gitStatusRefreshInFlight.set(targetKey, refreshPromise); |
| ... | ... | @@ -179,11 +190,62 @@ function unwatchGitStatus(targetKey: string): void { |
| 179 | 190 | watchedGitStatuses.delete(targetKey); |
| 180 | 191 | } |
| 181 | 192 | |
| 193 | function subscribeToGitStatusTarget( | |
| 194 | targetKey: string, | |
| 195 | target: GitStatusTarget, | |
| 196 | providedClient?: GitStatusClient, | |
| 197 | ): () => void { | |
| 198 | if (target.cwd === null) { | |
| 199 | return NOOP; | |
| 200 | } | |
| 201 | ||
| 202 | const cwd = target.cwd; | |
| 203 | let currentClientIdentity: string | null = null; | |
| 204 | let currentUnsubscribe = NOOP; | |
| 205 | ||
| 206 | const syncClientSubscription = () => { | |
| 207 | const resolved = providedClient | |
| 208 | ? { | |
| 209 | clientIdentity: `provided:${targetKey}`, | |
| 210 | client: providedClient, | |
| 211 | } | |
| 212 | : readResolvedGitStatusClient(target); | |
| 213 | ||
| 214 | if (!resolved) { | |
| 215 | if (currentClientIdentity !== null) { | |
| 216 | currentUnsubscribe(); | |
| 217 | currentUnsubscribe = NOOP; | |
| 218 | currentClientIdentity = null; | |
| 219 | } | |
| 220 | markGitStatusPending(targetKey); | |
| 221 | return; | |
| 222 | } | |
| 223 | ||
| 224 | if (currentClientIdentity === resolved.clientIdentity) { | |
| 225 | return; | |
| 226 | } | |
| 227 | ||
| 228 | currentUnsubscribe(); | |
| 229 | currentClientIdentity = resolved.clientIdentity; | |
| 230 | currentUnsubscribe = subscribeToGitStatus(targetKey, cwd, resolved.client); | |
| 231 | }; | |
| 232 | ||
| 233 | const unsubscribeRegistry = providedClient | |
| 234 | ? NOOP | |
| 235 | : subscribeEnvironmentConnections(syncClientSubscription); | |
| 236 | syncClientSubscription(); | |
| 237 | ||
| 238 | return () => { | |
| 239 | unsubscribeRegistry(); | |
| 240 | currentUnsubscribe(); | |
| 241 | }; | |
| 242 | } | |
| 243 | ||
| 182 | 244 | function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void { |
| 183 | 245 | markGitStatusPending(targetKey); |
| 184 | 246 | return client.onStatus( |
| 185 | 247 | { cwd }, |
| 186 | (status) => { | |
| 248 | (status: GitStatusResult) => { | |
| 187 | 249 | appAtomRegistry.set(gitStatusStateAtom(targetKey), { |
| 188 | 250 | data: status, |
| 189 | 251 | error: null, |
apps/web/src/lib/utils.test.ts+1-42| ... | ... | @@ -1,15 +1,5 @@ |
| 1 | import { assert, describe, expect, it, vi } from "vitest"; | |
| 2 | ||
| 3 | const { resolvePrimaryEnvironmentBootstrapUrlMock } = vi.hoisted(() => ({ | |
| 4 | resolvePrimaryEnvironmentBootstrapUrlMock: vi.fn(() => "http://bootstrap.test:4321"), | |
| 5 | })); | |
| 6 | ||
| 7 | vi.mock("../environmentBootstrap", () => ({ | |
| 8 | resolvePrimaryEnvironmentBootstrapUrl: resolvePrimaryEnvironmentBootstrapUrlMock, | |
| 9 | })); | |
| 10 | ||
| 1 | import { describe, assert, it } from "vitest"; | |
| 11 | 2 | import { isWindowsPlatform } from "./utils"; |
| 12 | import { resolveServerUrl } from "./utils"; | |
| 13 | 3 | |
| 14 | 4 | describe("isWindowsPlatform", () => { |
| 15 | 5 | it("matches Windows platform identifiers", () => { |
| ... | ... | @@ -22,34 +12,3 @@ describe("isWindowsPlatform", () => { |
| 22 | 12 | assert.isFalse(isWindowsPlatform("darwin")); |
| 23 | 13 | }); |
| 24 | 14 | }); |
| 25 | ||
| 26 | describe("resolveServerUrl", () => { | |
| 27 | it("falls back to the bootstrap environment URL when the explicit URL is empty", () => { | |
| 28 | expect(resolveServerUrl({ url: "" })).toBe("http://bootstrap.test:4321/"); | |
| 29 | }); | |
| 30 | ||
| 31 | it("uses the bootstrap environment URL when no explicit URL is provided", () => { | |
| 32 | expect(resolveServerUrl()).toBe("http://bootstrap.test:4321/"); | |
| 33 | }); | |
| 34 | ||
| 35 | it("prefers an explicit URL override", () => { | |
| 36 | expect( | |
| 37 | resolveServerUrl({ | |
| 38 | url: "https://override.test:9999", | |
| 39 | protocol: "wss", | |
| 40 | pathname: "/rpc", | |
| 41 | searchParams: { hello: "world" }, | |
| 42 | }), | |
| 43 | ).toBe("wss://override.test:9999/rpc?hello=world"); | |
| 44 | }); | |
| 45 | ||
| 46 | it("does not evaluate the bootstrap resolver when an explicit URL is provided", () => { | |
| 47 | resolvePrimaryEnvironmentBootstrapUrlMock.mockImplementationOnce(() => { | |
| 48 | throw new Error("bootstrap unavailable"); | |
| 49 | }); | |
| 50 | ||
| 51 | expect(resolveServerUrl({ url: "https://override.test:9999" })).toBe( | |
| 52 | "https://override.test:9999/", | |
| 53 | ); | |
| 54 | }); | |
| 55 | }); |
apps/web/src/lib/utils.ts-29| ... | ... | @@ -1,10 +1,8 @@ |
| 1 | 1 | import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { String, Predicate } from "effect"; | |
| 3 | 2 | import { type CxOptions, cx } from "class-variance-authority"; |
| 4 | 3 | import { twMerge } from "tailwind-merge"; |
| 5 | 4 | import * as Random from "effect/Random"; |
| 6 | 5 | import * as Effect from "effect/Effect"; |
| 7 | import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap"; | |
| 8 | 6 | import { DraftId } from "../composerDraftStore"; |
| 9 | 7 | |
| 10 | 8 | export function cn(...inputs: CxOptions) { |
| ... | ... | @@ -39,30 +37,3 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); |
| 39 | 37 | export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID()); |
| 40 | 38 | |
| 41 | 39 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); |
| 42 | ||
| 43 | const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty); | |
| 44 | ||
| 45 | export const resolveServerUrl = (options?: { | |
| 46 | url?: string | undefined; | |
| 47 | protocol?: "http" | "https" | "ws" | "wss" | undefined; | |
| 48 | pathname?: string | undefined; | |
| 49 | searchParams?: Record<string, string> | undefined; | |
| 50 | }): string => { | |
| 51 | const rawUrl = isNonEmptyString(options?.url) | |
| 52 | ? options.url | |
| 53 | : resolvePrimaryEnvironmentBootstrapUrl(); | |
| 54 | ||
| 55 | const parsedUrl = new URL(rawUrl); | |
| 56 | if (options?.protocol) { | |
| 57 | parsedUrl.protocol = options.protocol; | |
| 58 | } | |
| 59 | if (options?.pathname) { | |
| 60 | parsedUrl.pathname = options.pathname; | |
| 61 | } else { | |
| 62 | parsedUrl.pathname = "/"; | |
| 63 | } | |
| 64 | if (options?.searchParams) { | |
| 65 | parsedUrl.search = new URLSearchParams(options.searchParams).toString(); | |
| 66 | } | |
| 67 | return parsedUrl.toString(); | |
| 68 | }; |
apps/web/src/lib/vendor/qrcodegen.ts created+996| ... | ... | @@ -0,0 +1,996 @@ |
| 1 | // @ts-nocheck | |
| 2 | /* oxlint-disable eslint/no-useless-escape */ | |
| 3 | /* | |
| 4 | * QR Code generator library (TypeScript) | |
| 5 | * | |
| 6 | * Copyright (c) Project Nayuki. (MIT License) | |
| 7 | * https://www.nayuki.io/page/qr-code-generator-library | |
| 8 | * | |
| 9 | * Permission is hereby granted, free of charge, to any person obtaining a copy of | |
| 10 | * this software and associated documentation files (the "Software"), to deal in | |
| 11 | * the Software without restriction, including without limitation the rights to | |
| 12 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of | |
| 13 | * the Software, and to permit persons to whom the Software is furnished to do so, | |
| 14 | * subject to the following conditions: | |
| 15 | * - The above copyright notice and this permission notice shall be included in | |
| 16 | * all copies or substantial portions of the Software. | |
| 17 | * - The Software is provided "as is", without warranty of any kind, express or | |
| 18 | * implied, including but not limited to the warranties of merchantability, | |
| 19 | * fitness for a particular purpose and noninfringement. In no event shall the | |
| 20 | * authors or copyright holders be liable for any claim, damages or other | |
| 21 | * liability, whether in an action of contract, tort or otherwise, arising from, | |
| 22 | * out of or in connection with the Software or the use or other dealings in the | |
| 23 | * Software. | |
| 24 | */ | |
| 25 | ||
| 26 | "use strict"; | |
| 27 | ||
| 28 | ||
| 29 | namespace qrcodegen { | |
| 30 | 	 | |
| 31 | 	type bit = number; | |
| 32 | 	type byte = number; | |
| 33 | 	type int = number; | |
| 34 | 	 | |
| 35 | 	 | |
| 36 | 	/*---- QR Code symbol class ----*/ | |
| 37 | 	 | |
| 38 | 	/* | |
| 39 | 	 * A QR Code symbol, which is a type of two-dimension barcode. | |
| 40 | 	 * Invented by Denso Wave and described in the ISO/IEC 18004 standard. | |
| 41 | 	 * Instances of this class represent an immutable square grid of dark and light cells. | |
| 42 | 	 * The class provides static factory functions to create a QR Code from text or binary data. | |
| 43 | 	 * The class covers the QR Code Model 2 specification, supporting all versions (sizes) | |
| 44 | 	 * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. | |
| 45 | 	 * | |
| 46 | 	 * Ways to create a QR Code object: | |
| 47 | 	 * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). | |
| 48 | 	 * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). | |
| 49 | 	 * - Low level: Custom-make the array of data codeword bytes (including | |
| 50 | 	 * segment headers and final padding, excluding error correction codewords), | |
| 51 | 	 * supply the appropriate version number, and call the QrCode() constructor. | |
| 52 | 	 * (Note that all ways require supplying the desired error correction level.) | |
| 53 | 	 */ | |
| 54 | 	export class QrCode { | |
| 55 | 		 | |
| 56 | 		/*-- Static factory functions (high level) --*/ | |
| 57 | 		 | |
| 58 | 		// Returns a QR Code representing the given Unicode text string at the given error correction level. | |
| 59 | 		// As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer | |
| 60 | 		// Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible | |
| 61 | 		// QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the | |
| 62 | 		// ecl argument if it can be done without increasing the version. | |
| 63 | 		public static encodeText(text: string, ecl: QrCode.Ecc): QrCode { | |
| 64 | 			const segs: Array<QrSegment> = qrcodegen.QrSegment.makeSegments(text); | |
| 65 | 			return QrCode.encodeSegments(segs, ecl); | |
| 66 | 		} | |
| 67 | 		 | |
| 68 | 		 | |
| 69 | 		// Returns a QR Code representing the given binary data at the given error correction level. | |
| 70 | 		// This function always encodes using the binary segment mode, not any text mode. The maximum number of | |
| 71 | 		// bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. | |
| 72 | 		// The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. | |
| 73 | 		public static encodeBinary(data: Readonly<Array<byte>>, ecl: QrCode.Ecc): QrCode { | |
| 74 | 			const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data); | |
| 75 | 			return QrCode.encodeSegments([seg], ecl); | |
| 76 | 		} | |
| 77 | 		 | |
| 78 | 		 | |
| 79 | 		/*-- Static factory functions (mid level) --*/ | |
| 80 | 		 | |
| 81 | 		// Returns a QR Code representing the given segments with the given encoding parameters. | |
| 82 | 		// The smallest possible QR Code version within the given range is automatically | |
| 83 | 		// chosen for the output. Iff boostEcl is true, then the ECC level of the result | |
| 84 | 		// may be higher than the ecl argument if it can be done without increasing the | |
| 85 | 		// version. The mask number is either between 0 to 7 (inclusive) to force that | |
| 86 | 		// mask, or -1 to automatically choose an appropriate mask (which may be slow). | |
| 87 | 		// This function allows the user to create a custom sequence of segments that switches | |
| 88 | 		// between modes (such as alphanumeric and byte) to encode text in less space. | |
| 89 | 		// This is a mid-level API; the high-level API is encodeText() and encodeBinary(). | |
| 90 | 		public static encodeSegments(segs: Readonly<Array<QrSegment>>, ecl: QrCode.Ecc, | |
| 91 | 				minVersion: int = 1, maxVersion: int = 40, | |
| 92 | 				mask: int = -1, boostEcl: boolean = true): QrCode { | |
| 93 | 			 | |
| 94 | 			if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION) | |
| 95 | 					|| mask < -1 || mask > 7) | |
| 96 | 				throw new RangeError("Invalid value"); | |
| 97 | 			 | |
| 98 | 			// Find the minimal version number to use | |
| 99 | 			let version: int; | |
| 100 | 			let dataUsedBits: int; | |
| 101 | 			for (version = minVersion; ; version++) { | |
| 102 | 				const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available | |
| 103 | 				const usedBits: number = QrSegment.getTotalBits(segs, version); | |
| 104 | 				if (usedBits <= dataCapacityBits) { | |
| 105 | 					dataUsedBits = usedBits; | |
| 106 | 					break; // This version number is found to be suitable | |
| 107 | 				} | |
| 108 | 				if (version >= maxVersion) // All versions in the range could not fit the given data | |
| 109 | 					throw new RangeError("Data too long"); | |
| 110 | 			} | |
| 111 | 			 | |
| 112 | 			// Increase the error correction level while the data still fits in the current version number | |
| 113 | 			for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { // From low to high | |
| 114 | 				if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) | |
| 115 | 					ecl = newEcl; | |
| 116 | 			} | |
| 117 | 			 | |
| 118 | 			// Concatenate all segments to create the data bit string | |
| 119 | 			let bb: Array<bit> = [] | |
| 120 | 			for (const seg of segs) { | |
| 121 | 				appendBits(seg.mode.modeBits, 4, bb); | |
| 122 | 				appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); | |
| 123 | 				for (const b of seg.getData()) | |
| 124 | 					bb.push(b); | |
| 125 | 			} | |
| 126 | 			assert(bb.length == dataUsedBits); | |
| 127 | 			 | |
| 128 | 			// Add terminator and pad up to a byte if applicable | |
| 129 | 			const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; | |
| 130 | 			assert(bb.length <= dataCapacityBits); | |
| 131 | 			appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); | |
| 132 | 			appendBits(0, (8 - bb.length % 8) % 8, bb); | |
| 133 | 			assert(bb.length % 8 == 0); | |
| 134 | 			 | |
| 135 | 			// Pad with alternating bytes until data capacity is reached | |
| 136 | 			for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11) | |
| 137 | 				appendBits(padByte, 8, bb); | |
| 138 | 			 | |
| 139 | 			// Pack bits into bytes in big endian | |
| 140 | 			let dataCodewords: Array<byte> = []; | |
| 141 | 			while (dataCodewords.length * 8 < bb.length) | |
| 142 | 				dataCodewords.push(0); | |
| 143 | 			bb.forEach((b: bit, i: int) => | |
| 144 | 				dataCodewords[i >>> 3] |= b << (7 - (i & 7))); | |
| 145 | 			 | |
| 146 | 			// Create the QR Code object | |
| 147 | 			return new QrCode(version, ecl, dataCodewords, mask); | |
| 148 | 		} | |
| 149 | 		 | |
| 150 | 		 | |
| 151 | 		/*-- Fields --*/ | |
| 152 | 		 | |
| 153 | 		// The width and height of this QR Code, measured in modules, between | |
| 154 | 		// 21 and 177 (inclusive). This is equal to version * 4 + 17. | |
| 155 | 		public readonly size: int; | |
| 156 | 		 | |
| 157 | 		// The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). | |
| 158 | 		// Even if a QR Code is created with automatic masking requested (mask = -1), | |
| 159 | 		// the resulting object still has a mask value between 0 and 7. | |
| 160 | 		public readonly mask: int; | |
| 161 | 		 | |
| 162 | 		// The modules of this QR Code (false = light, true = dark). | |
| 163 | 		// Immutable after constructor finishes. Accessed through getModule(). | |
| 164 | 		private readonly modules : Array<Array<boolean>> = []; | |
| 165 | 		 | |
| 166 | 		// Indicates function modules that are not subjected to masking. Discarded when constructor finishes. | |
| 167 | 		private readonly isFunction: Array<Array<boolean>> = []; | |
| 168 | 		 | |
| 169 | 		 | |
| 170 | 		/*-- Constructor (low level) and fields --*/ | |
| 171 | 		 | |
| 172 | 		// Creates a new QR Code with the given version number, | |
| 173 | 		// error correction level, data codeword bytes, and mask number. | |
| 174 | 		// This is a low-level API that most users should not use directly. | |
| 175 | 		// A mid-level API is the encodeSegments() function. | |
| 176 | 		public constructor( | |
| 177 | 				// The version number of this QR Code, which is between 1 and 40 (inclusive). | |
| 178 | 				// This determines the size of this barcode. | |
| 179 | 				public readonly version: int, | |
| 180 | 				 | |
| 181 | 				// The error correction level used in this QR Code. | |
| 182 | 				public readonly errorCorrectionLevel: QrCode.Ecc, | |
| 183 | 				 | |
| 184 | 				dataCodewords: Readonly<Array<byte>>, | |
| 185 | 				 | |
| 186 | 				msk: int) { | |
| 187 | 			 | |
| 188 | 			// Check scalar arguments | |
| 189 | 			if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) | |
| 190 | 				throw new RangeError("Version value out of range"); | |
| 191 | 			if (msk < -1 || msk > 7) | |
| 192 | 				throw new RangeError("Mask value out of range"); | |
| 193 | 			this.size = version * 4 + 17; | |
| 194 | 			 | |
| 195 | 			// Initialize both grids to be size*size arrays of Boolean false | |
| 196 | 			let row: Array<boolean> = []; | |
| 197 | 			for (let i = 0; i < this.size; i++) | |
| 198 | 				row.push(false); | |
| 199 | 			for (let i = 0; i < this.size; i++) { | |
| 200 | 				this.modules .push(row.slice()); // Initially all light | |
| 201 | 				this.isFunction.push(row.slice()); | |
| 202 | 			} | |
| 203 | 			 | |
| 204 | 			// Compute ECC, draw modules | |
| 205 | 			this.drawFunctionPatterns(); | |
| 206 | 			const allCodewords: Array<byte> = this.addEccAndInterleave(dataCodewords); | |
| 207 | 			this.drawCodewords(allCodewords); | |
| 208 | 			 | |
| 209 | 			// Do masking | |
| 210 | 			if (msk == -1) { // Automatically choose best mask | |
| 211 | 				let minPenalty: int = 1000000000; | |
| 212 | 				for (let i = 0; i < 8; i++) { | |
| 213 | 					this.applyMask(i); | |
| 214 | 					this.drawFormatBits(i); | |
| 215 | 					const penalty: int = this.getPenaltyScore(); | |
| 216 | 					if (penalty < minPenalty) { | |
| 217 | 						msk = i; | |
| 218 | 						minPenalty = penalty; | |
| 219 | 					} | |
| 220 | 					this.applyMask(i); // Undoes the mask due to XOR | |
| 221 | 				} | |
| 222 | 			} | |
| 223 | 			assert(0 <= msk && msk <= 7); | |
| 224 | 			this.mask = msk; | |
| 225 | 			this.applyMask(msk); // Apply the final choice of mask | |
| 226 | 			this.drawFormatBits(msk); // Overwrite old format bits | |
| 227 | 			 | |
| 228 | 			this.isFunction = []; | |
| 229 | 		} | |
| 230 | 		 | |
| 231 | 		 | |
| 232 | 		/*-- Accessor methods --*/ | |
| 233 | 		 | |
| 234 | 		// Returns the color of the module (pixel) at the given coordinates, which is false | |
| 235 | 		// for light or true for dark. The top left corner has the coordinates (x=0, y=0). | |
| 236 | 		// If the given coordinates are out of bounds, then false (light) is returned. | |
| 237 | 		public getModule(x: int, y: int): boolean { | |
| 238 | 			return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; | |
| 239 | 		} | |
| 240 | 		 | |
| 241 | 		 | |
| 242 | 		/*-- Private helper methods for constructor: Drawing function modules --*/ | |
| 243 | 		 | |
| 244 | 		// Reads this object's version field, and draws and marks all function modules. | |
| 245 | 		private drawFunctionPatterns(): void { | |
| 246 | 			// Draw horizontal and vertical timing patterns | |
| 247 | 			for (let i = 0; i < this.size; i++) { | |
| 248 | 				this.setFunctionModule(6, i, i % 2 == 0); | |
| 249 | 				this.setFunctionModule(i, 6, i % 2 == 0); | |
| 250 | 			} | |
| 251 | 			 | |
| 252 | 			// Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) | |
| 253 | 			this.drawFinderPattern(3, 3); | |
| 254 | 			this.drawFinderPattern(this.size - 4, 3); | |
| 255 | 			this.drawFinderPattern(3, this.size - 4); | |
| 256 | 			 | |
| 257 | 			// Draw numerous alignment patterns | |
| 258 | 			const alignPatPos: Array<int> = this.getAlignmentPatternPositions(); | |
| 259 | 			const numAlign: int = alignPatPos.length; | |
| 260 | 			for (let i = 0; i < numAlign; i++) { | |
| 261 | 				for (let j = 0; j < numAlign; j++) { | |
| 262 | 					// Don't draw on the three finder corners | |
| 263 | 					if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0)) | |
| 264 | 						this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); | |
| 265 | 				} | |
| 266 | 			} | |
| 267 | 			 | |
| 268 | 			// Draw configuration data | |
| 269 | 			this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor | |
| 270 | 			this.drawVersion(); | |
| 271 | 		} | |
| 272 | 		 | |
| 273 | 		 | |
| 274 | 		// Draws two copies of the format bits (with its own error correction code) | |
| 275 | 		// based on the given mask and this object's error correction level field. | |
| 276 | 		private drawFormatBits(mask: int): void { | |
| 277 | 			// Calculate error correction code and pack bits | |
| 278 | 			const data: int = this.errorCorrectionLevel.formatBits << 3 | mask; // errCorrLvl is uint2, mask is uint3 | |
| 279 | 			let rem: int = data; | |
| 280 | 			for (let i = 0; i < 10; i++) | |
| 281 | 				rem = (rem << 1) ^ ((rem >>> 9) * 0x537); | |
| 282 | 			const bits = (data << 10 | rem) ^ 0x5412; // uint15 | |
| 283 | 			assert(bits >>> 15 == 0); | |
| 284 | 			 | |
| 285 | 			// Draw first copy | |
| 286 | 			for (let i = 0; i <= 5; i++) | |
| 287 | 				this.setFunctionModule(8, i, getBit(bits, i)); | |
| 288 | 			this.setFunctionModule(8, 7, getBit(bits, 6)); | |
| 289 | 			this.setFunctionModule(8, 8, getBit(bits, 7)); | |
| 290 | 			this.setFunctionModule(7, 8, getBit(bits, 8)); | |
| 291 | 			for (let i = 9; i < 15; i++) | |
| 292 | 				this.setFunctionModule(14 - i, 8, getBit(bits, i)); | |
| 293 | 			 | |
| 294 | 			// Draw second copy | |
| 295 | 			for (let i = 0; i < 8; i++) | |
| 296 | 				this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); | |
| 297 | 			for (let i = 8; i < 15; i++) | |
| 298 | 				this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); | |
| 299 | 			this.setFunctionModule(8, this.size - 8, true); // Always dark | |
| 300 | 		} | |
| 301 | 		 | |
| 302 | 		 | |
| 303 | 		// Draws two copies of the version bits (with its own error correction code), | |
| 304 | 		// based on this object's version field, iff 7 <= version <= 40. | |
| 305 | 		private drawVersion(): void { | |
| 306 | 			if (this.version < 7) | |
| 307 | 				return; | |
| 308 | 			 | |
| 309 | 			// Calculate error correction code and pack bits | |
| 310 | 			let rem: int = this.version; // version is uint6, in the range [7, 40] | |
| 311 | 			for (let i = 0; i < 12; i++) | |
| 312 | 				rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25); | |
| 313 | 			const bits: int = this.version << 12 | rem; // uint18 | |
| 314 | 			assert(bits >>> 18 == 0); | |
| 315 | 			 | |
| 316 | 			// Draw two copies | |
| 317 | 			for (let i = 0; i < 18; i++) { | |
| 318 | 				const color: boolean = getBit(bits, i); | |
| 319 | 				const a: int = this.size - 11 + i % 3; | |
| 320 | 				const b: int = Math.floor(i / 3); | |
| 321 | 				this.setFunctionModule(a, b, color); | |
| 322 | 				this.setFunctionModule(b, a, color); | |
| 323 | 			} | |
| 324 | 		} | |
| 325 | 		 | |
| 326 | 		 | |
| 327 | 		// Draws a 9*9 finder pattern including the border separator, | |
| 328 | 		// with the center module at (x, y). Modules can be out of bounds. | |
| 329 | 		private drawFinderPattern(x: int, y: int): void { | |
| 330 | 			for (let dy = -4; dy <= 4; dy++) { | |
| 331 | 				for (let dx = -4; dx <= 4; dx++) { | |
| 332 | 					const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm | |
| 333 | 					const xx: int = x + dx; | |
| 334 | 					const yy: int = y + dy; | |
| 335 | 					if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) | |
| 336 | 						this.setFunctionModule(xx, yy, dist != 2 && dist != 4); | |
| 337 | 				} | |
| 338 | 			} | |
| 339 | 		} | |
| 340 | 		 | |
| 341 | 		 | |
| 342 | 		// Draws a 5*5 alignment pattern, with the center module | |
| 343 | 		// at (x, y). All modules must be in bounds. | |
| 344 | 		private drawAlignmentPattern(x: int, y: int): void { | |
| 345 | 			for (let dy = -2; dy <= 2; dy++) { | |
| 346 | 				for (let dx = -2; dx <= 2; dx++) | |
| 347 | 					this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); | |
| 348 | 			} | |
| 349 | 		} | |
| 350 | 		 | |
| 351 | 		 | |
| 352 | 		// Sets the color of a module and marks it as a function module. | |
| 353 | 		// Only used by the constructor. Coordinates must be in bounds. | |
| 354 | 		private setFunctionModule(x: int, y: int, isDark: boolean): void { | |
| 355 | 			this.modules[y][x] = isDark; | |
| 356 | 			this.isFunction[y][x] = true; | |
| 357 | 		} | |
| 358 | 		 | |
| 359 | 		 | |
| 360 | 		/*-- Private helper methods for constructor: Codewords and masking --*/ | |
| 361 | 		 | |
| 362 | 		// Returns a new byte string representing the given data with the appropriate error correction | |
| 363 | 		// codewords appended to it, based on this object's version and error correction level. | |
| 364 | 		private addEccAndInterleave(data: Readonly<Array<byte>>): Array<byte> { | |
| 365 | 			const ver: int = this.version; | |
| 366 | 			const ecl: QrCode.Ecc = this.errorCorrectionLevel; | |
| 367 | 			if (data.length != QrCode.getNumDataCodewords(ver, ecl)) | |
| 368 | 				throw new RangeError("Invalid argument"); | |
| 369 | 			 | |
| 370 | 			// Calculate parameter numbers | |
| 371 | 			const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; | |
| 372 | 			const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver]; | |
| 373 | 			const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); | |
| 374 | 			const numShortBlocks: int = numBlocks - rawCodewords % numBlocks; | |
| 375 | 			const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); | |
| 376 | 			 | |
| 377 | 			// Split data into blocks and append ECC to each block | |
| 378 | 			let blocks: Array<Array<byte>> = []; | |
| 379 | 			const rsDiv: Array<byte> = QrCode.reedSolomonComputeDivisor(blockEccLen); | |
| 380 | 			for (let i = 0, k = 0; i < numBlocks; i++) { | |
| 381 | 				let dat: Array<byte> = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)); | |
| 382 | 				k += dat.length; | |
| 383 | 				const ecc: Array<byte> = QrCode.reedSolomonComputeRemainder(dat, rsDiv); | |
| 384 | 				if (i < numShortBlocks) | |
| 385 | 					dat.push(0); | |
| 386 | 				blocks.push(dat.concat(ecc)); | |
| 387 | 			} | |
| 388 | 			 | |
| 389 | 			// Interleave (not concatenate) the bytes from every block into a single sequence | |
| 390 | 			let result: Array<byte> = []; | |
| 391 | 			for (let i = 0; i < blocks[0].length; i++) { | |
| 392 | 				blocks.forEach((block, j) => { | |
| 393 | 					// Skip the padding byte in short blocks | |
| 394 | 					if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) | |
| 395 | 						result.push(block[i]); | |
| 396 | 				}); | |
| 397 | 			} | |
| 398 | 			assert(result.length == rawCodewords); | |
| 399 | 			return result; | |
| 400 | 		} | |
| 401 | 		 | |
| 402 | 		 | |
| 403 | 		// Draws the given sequence of 8-bit codewords (data and error correction) onto the entire | |
| 404 | 		// data area of this QR Code. Function modules need to be marked off before this is called. | |
| 405 | 		private drawCodewords(data: Readonly<Array<byte>>): void { | |
| 406 | 			if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) | |
| 407 | 				throw new RangeError("Invalid argument"); | |
| 408 | 			let i: int = 0; // Bit index into the data | |
| 409 | 			// Do the funny zigzag scan | |
| 410 | 			for (let right = this.size - 1; right >= 1; right -= 2) { // Index of right column in each column pair | |
| 411 | 				if (right == 6) | |
| 412 | 					right = 5; | |
| 413 | 				for (let vert = 0; vert < this.size; vert++) { // Vertical counter | |
| 414 | 					for (let j = 0; j < 2; j++) { | |
| 415 | 						const x: int = right - j; // Actual x coordinate | |
| 416 | 						const upward: boolean = ((right + 1) & 2) == 0; | |
| 417 | 						const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate | |
| 418 | 						if (!this.isFunction[y][x] && i < data.length * 8) { | |
| 419 | 							this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); | |
| 420 | 							i++; | |
| 421 | 						} | |
| 422 | 						// If this QR Code has any remainder bits (0 to 7), they were assigned as | |
| 423 | 						// 0/false/light by the constructor and are left unchanged by this method | |
| 424 | 					} | |
| 425 | 				} | |
| 426 | 			} | |
| 427 | 			assert(i == data.length * 8); | |
| 428 | 		} | |
| 429 | 		 | |
| 430 | 		 | |
| 431 | 		// XORs the codeword modules in this QR Code with the given mask pattern. | |
| 432 | 		// The function modules must be marked and the codeword bits must be drawn | |
| 433 | 		// before masking. Due to the arithmetic of XOR, calling applyMask() with | |
| 434 | 		// the same mask value a second time will undo the mask. A final well-formed | |
| 435 | 		// QR Code needs exactly one (not zero, two, etc.) mask applied. | |
| 436 | 		private applyMask(mask: int): void { | |
| 437 | 			if (mask < 0 || mask > 7) | |
| 438 | 				throw new RangeError("Mask value out of range"); | |
| 439 | 			for (let y = 0; y < this.size; y++) { | |
| 440 | 				for (let x = 0; x < this.size; x++) { | |
| 441 | 					let invert: boolean; | |
| 442 | 					switch (mask) { | |
| 443 | 						case 0: invert = (x + y) % 2 == 0; break; | |
| 444 | 						case 1: invert = y % 2 == 0; break; | |
| 445 | 						case 2: invert = x % 3 == 0; break; | |
| 446 | 						case 3: invert = (x + y) % 3 == 0; break; | |
| 447 | 						case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; break; | |
| 448 | 						case 5: invert = x * y % 2 + x * y % 3 == 0; break; | |
| 449 | 						case 6: invert = (x * y % 2 + x * y % 3) % 2 == 0; break; | |
| 450 | 						case 7: invert = ((x + y) % 2 + x * y % 3) % 2 == 0; break; | |
| 451 | 						default: throw new Error("Unreachable"); | |
| 452 | 					} | |
| 453 | 					if (!this.isFunction[y][x] && invert) | |
| 454 | 						this.modules[y][x] = !this.modules[y][x]; | |
| 455 | 				} | |
| 456 | 			} | |
| 457 | 		} | |
| 458 | 		 | |
| 459 | 		 | |
| 460 | 		// Calculates and returns the penalty score based on state of this QR Code's current modules. | |
| 461 | 		// This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. | |
| 462 | 		private getPenaltyScore(): int { | |
| 463 | 			let result: int = 0; | |
| 464 | 			 | |
| 465 | 			// Adjacent modules in row having same color, and finder-like patterns | |
| 466 | 			for (let y = 0; y < this.size; y++) { | |
| 467 | 				let runColor = false; | |
| 468 | 				let runX = 0; | |
| 469 | 				let runHistory = [0,0,0,0,0,0,0]; | |
| 470 | 				for (let x = 0; x < this.size; x++) { | |
| 471 | 					if (this.modules[y][x] == runColor) { | |
| 472 | 						runX++; | |
| 473 | 						if (runX == 5) | |
| 474 | 							result += QrCode.PENALTY_N1; | |
| 475 | 						else if (runX > 5) | |
| 476 | 							result++; | |
| 477 | 					} else { | |
| 478 | 						this.finderPenaltyAddHistory(runX, runHistory); | |
| 479 | 						if (!runColor) | |
| 480 | 							result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; | |
| 481 | 						runColor = this.modules[y][x]; | |
| 482 | 						runX = 1; | |
| 483 | 					} | |
| 484 | 				} | |
| 485 | 				result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; | |
| 486 | 			} | |
| 487 | 			// Adjacent modules in column having same color, and finder-like patterns | |
| 488 | 			for (let x = 0; x < this.size; x++) { | |
| 489 | 				let runColor = false; | |
| 490 | 				let runY = 0; | |
| 491 | 				let runHistory = [0,0,0,0,0,0,0]; | |
| 492 | 				for (let y = 0; y < this.size; y++) { | |
| 493 | 					if (this.modules[y][x] == runColor) { | |
| 494 | 						runY++; | |
| 495 | 						if (runY == 5) | |
| 496 | 							result += QrCode.PENALTY_N1; | |
| 497 | 						else if (runY > 5) | |
| 498 | 							result++; | |
| 499 | 					} else { | |
| 500 | 						this.finderPenaltyAddHistory(runY, runHistory); | |
| 501 | 						if (!runColor) | |
| 502 | 							result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; | |
| 503 | 						runColor = this.modules[y][x]; | |
| 504 | 						runY = 1; | |
| 505 | 					} | |
| 506 | 				} | |
| 507 | 				result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; | |
| 508 | 			} | |
| 509 | 			 | |
| 510 | 			// 2*2 blocks of modules having same color | |
| 511 | 			for (let y = 0; y < this.size - 1; y++) { | |
| 512 | 				for (let x = 0; x < this.size - 1; x++) { | |
| 513 | 					const color: boolean = this.modules[y][x]; | |
| 514 | 					if ( color == this.modules[y][x + 1] && | |
| 515 | 					 color == this.modules[y + 1][x] && | |
| 516 | 					 color == this.modules[y + 1][x + 1]) | |
| 517 | 						result += QrCode.PENALTY_N2; | |
| 518 | 				} | |
| 519 | 			} | |
| 520 | 			 | |
| 521 | 			// Balance of dark and light modules | |
| 522 | 			let dark: int = 0; | |
| 523 | 			for (const row of this.modules) | |
| 524 | 				dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); | |
| 525 | 			const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 | |
| 526 | 			// Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% | |
| 527 | 			const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; | |
| 528 | 			assert(0 <= k && k <= 9); | |
| 529 | 			result += k * QrCode.PENALTY_N4; | |
| 530 | 			assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 | |
| 531 | 			return result; | |
| 532 | 		} | |
| 533 | 		 | |
| 534 | 		 | |
| 535 | 		/*-- Private helper functions --*/ | |
| 536 | 		 | |
| 537 | 		// Returns an ascending list of positions of alignment patterns for this version number. | |
| 538 | 		// Each position is in the range [0,177), and are used on both the x and y axes. | |
| 539 | 		// This could be implemented as lookup table of 40 variable-length lists of integers. | |
| 540 | 		private getAlignmentPatternPositions(): Array<int> { | |
| 541 | 			if (this.version == 1) | |
| 542 | 				return []; | |
| 543 | 			else { | |
| 544 | 				const numAlign: int = Math.floor(this.version / 7) + 2; | |
| 545 | 				const step: int = (this.version == 32) ? 26 : | |
| 546 | 					Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; | |
| 547 | 				let result: Array<int> = [6]; | |
| 548 | 				for (let pos = this.size - 7; result.length < numAlign; pos -= step) | |
| 549 | 					result.splice(1, 0, pos); | |
| 550 | 				return result; | |
| 551 | 			} | |
| 552 | 		} | |
| 553 | 		 | |
| 554 | 		 | |
| 555 | 		// Returns the number of data bits that can be stored in a QR Code of the given version number, after | |
| 556 | 		// all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. | |
| 557 | 		// The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. | |
| 558 | 		private static getNumRawDataModules(ver: int): int { | |
| 559 | 			if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) | |
| 560 | 				throw new RangeError("Version number out of range"); | |
| 561 | 			let result: int = (16 * ver + 128) * ver + 64; | |
| 562 | 			if (ver >= 2) { | |
| 563 | 				const numAlign: int = Math.floor(ver / 7) + 2; | |
| 564 | 				result -= (25 * numAlign - 10) * numAlign - 55; | |
| 565 | 				if (ver >= 7) | |
| 566 | 					result -= 36; | |
| 567 | 			} | |
| 568 | 			assert(208 <= result && result <= 29648); | |
| 569 | 			return result; | |
| 570 | 		} | |
| 571 | 		 | |
| 572 | 		 | |
| 573 | 		// Returns the number of 8-bit data (i.e. not error correction) codewords contained in any | |
| 574 | 		// QR Code of the given version number and error correction level, with remainder bits discarded. | |
| 575 | 		// This stateless pure function could be implemented as a (40*4)-cell lookup table. | |
| 576 | 		private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int { | |
| 577 | 			return Math.floor(QrCode.getNumRawDataModules(ver) / 8) - | |
| 578 | 				QrCode.ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver] * | |
| 579 | 				QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; | |
| 580 | 		} | |
| 581 | 		 | |
| 582 | 		 | |
| 583 | 		// Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be | |
| 584 | 		// implemented as a lookup table over all possible parameter values, instead of as an algorithm. | |
| 585 | 		private static reedSolomonComputeDivisor(degree: int): Array<byte> { | |
| 586 | 			if (degree < 1 || degree > 255) | |
| 587 | 				throw new RangeError("Degree out of range"); | |
| 588 | 			// Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. | |
| 589 | 			// For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. | |
| 590 | 			let result: Array<byte> = []; | |
| 591 | 			for (let i = 0; i < degree - 1; i++) | |
| 592 | 				result.push(0); | |
| 593 | 			result.push(1); // Start off with the monomial x^0 | |
| 594 | 			 | |
| 595 | 			// Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), | |
| 596 | 			// and drop the highest monomial term which is always 1x^degree. | |
| 597 | 			// Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). | |
| 598 | 			let root = 1; | |
| 599 | 			for (let i = 0; i < degree; i++) { | |
| 600 | 				// Multiply the current product by (x - r^i) | |
| 601 | 				for (let j = 0; j < result.length; j++) { | |
| 602 | 					result[j] = QrCode.reedSolomonMultiply(result[j], root); | |
| 603 | 					if (j + 1 < result.length) | |
| 604 | 						result[j] ^= result[j + 1]; | |
| 605 | 				} | |
| 606 | 				root = QrCode.reedSolomonMultiply(root, 0x02); | |
| 607 | 			} | |
| 608 | 			return result; | |
| 609 | 		} | |
| 610 | 		 | |
| 611 | 		 | |
| 612 | 		// Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. | |
| 613 | 		private static reedSolomonComputeRemainder(data: Readonly<Array<byte>>, divisor: Readonly<Array<byte>>): Array<byte> { | |
| 614 | 			let result: Array<byte> = divisor.map(_ => 0); | |
| 615 | 			for (const b of data) { // Polynomial division | |
| 616 | 				const factor: byte = b ^ (result.shift() as byte); | |
| 617 | 				result.push(0); | |
| 618 | 				divisor.forEach((coef, i) => | |
| 619 | 					result[i] ^= QrCode.reedSolomonMultiply(coef, factor)); | |
| 620 | 			} | |
| 621 | 			return result; | |
| 622 | 		} | |
| 623 | 		 | |
| 624 | 		 | |
| 625 | 		// Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result | |
| 626 | 		// are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. | |
| 627 | 		private static reedSolomonMultiply(x: byte, y: byte): byte { | |
| 628 | 			if (x >>> 8 != 0 || y >>> 8 != 0) | |
| 629 | 				throw new RangeError("Byte out of range"); | |
| 630 | 			// Russian peasant multiplication | |
| 631 | 			let z: int = 0; | |
| 632 | 			for (let i = 7; i >= 0; i--) { | |
| 633 | 				z = (z << 1) ^ ((z >>> 7) * 0x11D); | |
| 634 | 				z ^= ((y >>> i) & 1) * x; | |
| 635 | 			} | |
| 636 | 			assert(z >>> 8 == 0); | |
| 637 | 			return z as byte; | |
| 638 | 		} | |
| 639 | 		 | |
| 640 | 		 | |
| 641 | 		// Can only be called immediately after a light run is added, and | |
| 642 | 		// returns either 0, 1, or 2. A helper function for getPenaltyScore(). | |
| 643 | 		private finderPenaltyCountPatterns(runHistory: Readonly<Array<int>>): int { | |
| 644 | 			const n: int = runHistory[1]; | |
| 645 | 			assert(n <= this.size * 3); | |
| 646 | 			const core: boolean = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n; | |
| 647 | 			return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) | |
| 648 | 			 + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0); | |
| 649 | 		} | |
| 650 | 		 | |
| 651 | 		 | |
| 652 | 		// Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). | |
| 653 | 		private finderPenaltyTerminateAndCount(currentRunColor: boolean, currentRunLength: int, runHistory: Array<int>): int { | |
| 654 | 			if (currentRunColor) { // Terminate dark run | |
| 655 | 				this.finderPenaltyAddHistory(currentRunLength, runHistory); | |
| 656 | 				currentRunLength = 0; | |
| 657 | 			} | |
| 658 | 			currentRunLength += this.size; // Add light border to final run | |
| 659 | 			this.finderPenaltyAddHistory(currentRunLength, runHistory); | |
| 660 | 			return this.finderPenaltyCountPatterns(runHistory); | |
| 661 | 		} | |
| 662 | 		 | |
| 663 | 		 | |
| 664 | 		// Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). | |
| 665 | 		private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array<int>): void { | |
| 666 | 			if (runHistory[0] == 0) | |
| 667 | 				currentRunLength += this.size; // Add light border to initial run | |
| 668 | 			runHistory.pop(); | |
| 669 | 			runHistory.unshift(currentRunLength); | |
| 670 | 		} | |
| 671 | 		 | |
| 672 | 		 | |
| 673 | 		/*-- Constants and tables --*/ | |
| 674 | 		 | |
| 675 | 		// The minimum version number supported in the QR Code Model 2 standard. | |
| 676 | 		public static readonly MIN_VERSION: int = 1; | |
| 677 | 		// The maximum version number supported in the QR Code Model 2 standard. | |
| 678 | 		public static readonly MAX_VERSION: int = 40; | |
| 679 | 		 | |
| 680 | 		// For use in getPenaltyScore(), when evaluating which mask is best. | |
| 681 | 		private static readonly PENALTY_N1: int = 3; | |
| 682 | 		private static readonly PENALTY_N2: int = 3; | |
| 683 | 		private static readonly PENALTY_N3: int = 40; | |
| 684 | 		private static readonly PENALTY_N4: int = 10; | |
| 685 | 		 | |
| 686 | 		private static readonly ECC_CODEWORDS_PER_BLOCK: Array<Array<int>> = [ | |
| 687 | 			// Version: (note that index 0 is for padding, and is set to an illegal value) | |
| 688 | 			//0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level | |
| 689 | 			[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Low | |
| 690 | 			[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], // Medium | |
| 691 | 			[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Quartile | |
| 692 | 			[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // High | |
| 693 | 		]; | |
| 694 | 		 | |
| 695 | 		private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array<Array<int>> = [ | |
| 696 | 			// Version: (note that index 0 is for padding, and is set to an illegal value) | |
| 697 | 			//0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level | |
| 698 | 			[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], // Low | |
| 699 | 			[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], // Medium | |
| 700 | 			[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], // Quartile | |
| 701 | 			[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // High | |
| 702 | 		]; | |
| 703 | 		 | |
| 704 | 	} | |
| 705 | 	 | |
| 706 | 	 | |
| 707 | 	// Appends the given number of low-order bits of the given value | |
| 708 | 	// to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. | |
| 709 | 	function appendBits(val: int, len: int, bb: Array<bit>): void { | |
| 710 | 		if (len < 0 || len > 31 || val >>> len != 0) | |
| 711 | 			throw new RangeError("Value out of range"); | |
| 712 | 		for (let i = len - 1; i >= 0; i--) // Append bit by bit | |
| 713 | 			bb.push((val >>> i) & 1); | |
| 714 | 	} | |
| 715 | 	 | |
| 716 | 	 | |
| 717 | 	// Returns true iff the i'th bit of x is set to 1. | |
| 718 | 	function getBit(x: int, i: int): boolean { | |
| 719 | 		return ((x >>> i) & 1) != 0; | |
| 720 | 	} | |
| 721 | 	 | |
| 722 | 	 | |
| 723 | 	// Throws an exception if the given condition is false. | |
| 724 | 	function assert(cond: boolean): void { | |
| 725 | 		if (!cond) | |
| 726 | 			throw new Error("Assertion error"); | |
| 727 | 	} | |
| 728 | 	 | |
| 729 | 	 | |
| 730 | 	 | |
| 731 | 	/*---- Data segment class ----*/ | |
| 732 | 	 | |
| 733 | 	/* | |
| 734 | 	 * A segment of character/binary/control data in a QR Code symbol. | |
| 735 | 	 * Instances of this class are immutable. | |
| 736 | 	 * The mid-level way to create a segment is to take the payload data | |
| 737 | 	 * and call a static factory function such as QrSegment.makeNumeric(). | |
| 738 | 	 * The low-level way to create a segment is to custom-make the bit buffer | |
| 739 | 	 * and call the QrSegment() constructor with appropriate values. | |
| 740 | 	 * This segment class imposes no length restrictions, but QR Codes have restrictions. | |
| 741 | 	 * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. | |
| 742 | 	 * Any segment longer than this is meaningless for the purpose of generating QR Codes. | |
| 743 | 	 */ | |
| 744 | 	export class QrSegment { | |
| 745 | 		 | |
| 746 | 		/*-- Static factory functions (mid level) --*/ | |
| 747 | 		 | |
| 748 | 		// Returns a segment representing the given binary data encoded in | |
| 749 | 		// byte mode. All input byte arrays are acceptable. Any text string | |
| 750 | 		// can be converted to UTF-8 bytes and encoded as a byte mode segment. | |
| 751 | 		public static makeBytes(data: Readonly<Array<byte>>): QrSegment { | |
| 752 | 			let bb: Array<bit> = [] | |
| 753 | 			for (const b of data) | |
| 754 | 				appendBits(b, 8, bb); | |
| 755 | 			return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); | |
| 756 | 		} | |
| 757 | 		 | |
| 758 | 		 | |
| 759 | 		// Returns a segment representing the given string of decimal digits encoded in numeric mode. | |
| 760 | 		public static makeNumeric(digits: string): QrSegment { | |
| 761 | 			if (!QrSegment.isNumeric(digits)) | |
| 762 | 				throw new RangeError("String contains non-numeric characters"); | |
| 763 | 			let bb: Array<bit> = [] | |
| 764 | 			for (let i = 0; i < digits.length; ) { // Consume up to 3 digits per iteration | |
| 765 | 				const n: int = Math.min(digits.length - i, 3); | |
| 766 | 				appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); | |
| 767 | 				i += n; | |
| 768 | 			} | |
| 769 | 			return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); | |
| 770 | 		} | |
| 771 | 		 | |
| 772 | 		 | |
| 773 | 		// Returns a segment representing the given text string encoded in alphanumeric mode. | |
| 774 | 		// The characters allowed are: 0 to 9, A to Z (uppercase only), space, | |
| 775 | 		// dollar, percent, asterisk, plus, hyphen, period, slash, colon. | |
| 776 | 		public static makeAlphanumeric(text: string): QrSegment { | |
| 777 | 			if (!QrSegment.isAlphanumeric(text)) | |
| 778 | 				throw new RangeError("String contains unencodable characters in alphanumeric mode"); | |
| 779 | 			let bb: Array<bit> = [] | |
| 780 | 			let i: int; | |
| 781 | 			for (i = 0; i + 2 <= text.length; i += 2) { // Process groups of 2 | |
| 782 | 				let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; | |
| 783 | 				temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); | |
| 784 | 				appendBits(temp, 11, bb); | |
| 785 | 			} | |
| 786 | 			if (i < text.length) // 1 character remaining | |
| 787 | 				appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); | |
| 788 | 			return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); | |
| 789 | 		} | |
| 790 | 		 | |
| 791 | 		 | |
| 792 | 		// Returns a new mutable list of zero or more segments to represent the given Unicode text string. | |
| 793 | 		// The result may use various segment modes and switch modes to optimize the length of the bit stream. | |
| 794 | 		public static makeSegments(text: string): Array<QrSegment> { | |
| 795 | 			// Select the most efficient segment encoding automatically | |
| 796 | 			if (text == "") | |
| 797 | 				return []; | |
| 798 | 			else if (QrSegment.isNumeric(text)) | |
| 799 | 				return [QrSegment.makeNumeric(text)]; | |
| 800 | 			else if (QrSegment.isAlphanumeric(text)) | |
| 801 | 				return [QrSegment.makeAlphanumeric(text)]; | |
| 802 | 			else | |
| 803 | 				return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; | |
| 804 | 		} | |
| 805 | 		 | |
| 806 | 		 | |
| 807 | 		// Returns a segment representing an Extended Channel Interpretation | |
| 808 | 		// (ECI) designator with the given assignment value. | |
| 809 | 		public static makeEci(assignVal: int): QrSegment { | |
| 810 | 			let bb: Array<bit> = [] | |
| 811 | 			if (assignVal < 0) | |
| 812 | 				throw new RangeError("ECI assignment value out of range"); | |
| 813 | 			else if (assignVal < (1 << 7)) | |
| 814 | 				appendBits(assignVal, 8, bb); | |
| 815 | 			else if (assignVal < (1 << 14)) { | |
| 816 | 				appendBits(0b10, 2, bb); | |
| 817 | 				appendBits(assignVal, 14, bb); | |
| 818 | 			} else if (assignVal < 1000000) { | |
| 819 | 				appendBits(0b110, 3, bb); | |
| 820 | 				appendBits(assignVal, 21, bb); | |
| 821 | 			} else | |
| 822 | 				throw new RangeError("ECI assignment value out of range"); | |
| 823 | 			return new QrSegment(QrSegment.Mode.ECI, 0, bb); | |
| 824 | 		} | |
| 825 | 		 | |
| 826 | 		 | |
| 827 | 		// Tests whether the given string can be encoded as a segment in numeric mode. | |
| 828 | 		// A string is encodable iff each character is in the range 0 to 9. | |
| 829 | 		public static isNumeric(text: string): boolean { | |
| 830 | 			return QrSegment.NUMERIC_REGEX.test(text); | |
| 831 | 		} | |
| 832 | 		 | |
| 833 | 		 | |
| 834 | 		// Tests whether the given string can be encoded as a segment in alphanumeric mode. | |
| 835 | 		// A string is encodable iff each character is in the following set: 0 to 9, A to Z | |
| 836 | 		// (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. | |
| 837 | 		public static isAlphanumeric(text: string): boolean { | |
| 838 | 			return QrSegment.ALPHANUMERIC_REGEX.test(text); | |
| 839 | 		} | |
| 840 | 		 | |
| 841 | 		 | |
| 842 | 		/*-- Constructor (low level) and fields --*/ | |
| 843 | 		 | |
| 844 | 		// Creates a new QR Code segment with the given attributes and data. | |
| 845 | 		// The character count (numChars) must agree with the mode and the bit buffer length, | |
| 846 | 		// but the constraint isn't checked. The given bit buffer is cloned and stored. | |
| 847 | 		public constructor( | |
| 848 | 				// The mode indicator of this segment. | |
| 849 | 				public readonly mode: QrSegment.Mode, | |
| 850 | 				 | |
| 851 | 				// The length of this segment's unencoded data. Measured in characters for | |
| 852 | 				// numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. | |
| 853 | 				// Always zero or positive. Not the same as the data's bit length. | |
| 854 | 				public readonly numChars: int, | |
| 855 | 				 | |
| 856 | 				// The data bits of this segment. Accessed through getData(). | |
| 857 | 				private readonly bitData: Array<bit>) { | |
| 858 | 			 | |
| 859 | 			if (numChars < 0) | |
| 860 | 				throw new RangeError("Invalid argument"); | |
| 861 | 			this.bitData = bitData.slice(); // Make defensive copy | |
| 862 | 		} | |
| 863 | 		 | |
| 864 | 		 | |
| 865 | 		/*-- Methods --*/ | |
| 866 | 		 | |
| 867 | 		// Returns a new copy of the data bits of this segment. | |
| 868 | 		public getData(): Array<bit> { | |
| 869 | 			return this.bitData.slice(); // Make defensive copy | |
| 870 | 		} | |
| 871 | 		 | |
| 872 | 		 | |
| 873 | 		// (Package-private) Calculates and returns the number of bits needed to encode the given segments at | |
| 874 | 		// the given version. The result is infinity if a segment has too many characters to fit its length field. | |
| 875 | 		public static getTotalBits(segs: Readonly<Array<QrSegment>>, version: int): number { | |
| 876 | 			let result: number = 0; | |
| 877 | 			for (const seg of segs) { | |
| 878 | 				const ccbits: int = seg.mode.numCharCountBits(version); | |
| 879 | 				if (seg.numChars >= (1 << ccbits)) | |
| 880 | 					return Infinity; // The segment's length doesn't fit the field's bit width | |
| 881 | 				result += 4 + ccbits + seg.bitData.length; | |
| 882 | 			} | |
| 883 | 			return result; | |
| 884 | 		} | |
| 885 | 		 | |
| 886 | 		 | |
| 887 | 		// Returns a new array of bytes representing the given string encoded in UTF-8. | |
| 888 | 		private static toUtf8ByteArray(str: string): Array<byte> { | |
| 889 | 			str = encodeURI(str); | |
| 890 | 			let result: Array<byte> = []; | |
| 891 | 			for (let i = 0; i < str.length; i++) { | |
| 892 | 				if (str.charAt(i) != "%") | |
| 893 | 					result.push(str.charCodeAt(i)); | |
| 894 | 				else { | |
| 895 | 					result.push(parseInt(str.substring(i + 1, i + 3), 16)); | |
| 896 | 					i += 2; | |
| 897 | 				} | |
| 898 | 			} | |
| 899 | 			return result; | |
| 900 | 		} | |
| 901 | 		 | |
| 902 | 		 | |
| 903 | 		/*-- Constants --*/ | |
| 904 | 		 | |
| 905 | 		// Describes precisely all strings that are encodable in numeric mode. | |
| 906 | 		private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; | |
| 907 | 		 | |
| 908 | 		// Describes precisely all strings that are encodable in alphanumeric mode. | |
| 909 | 		private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; | |
| 910 | 		 | |
| 911 | 		// The set of all legal characters in alphanumeric mode, | |
| 912 | 		// where each character value maps to the index in the string. | |
| 913 | 		private static readonly ALPHANUMERIC_CHARSET: string = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; | |
| 914 | 		 | |
| 915 | 	} | |
| 916 | 	 | |
| 917 | } | |
| 918 | ||
| 919 | ||
| 920 | ||
| 921 | /*---- Public helper enumeration ----*/ | |
| 922 | ||
| 923 | namespace qrcodegen.QrCode { | |
| 924 | 	 | |
| 925 | 	type int = number; | |
| 926 | 	 | |
| 927 | 	 | |
| 928 | 	/* | |
| 929 | 	 * The error correction level in a QR Code symbol. Immutable. | |
| 930 | 	 */ | |
| 931 | 	export class Ecc { | |
| 932 | 		 | |
| 933 | 		/*-- Constants --*/ | |
| 934 | 		 | |
| 935 | 		public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords | |
| 936 | 		public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords | |
| 937 | 		public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords | |
| 938 | 		public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords | |
| 939 | 		 | |
| 940 | 		 | |
| 941 | 		/*-- Constructor and fields --*/ | |
| 942 | 		 | |
| 943 | 		private constructor( | |
| 944 | 			// In the range 0 to 3 (unsigned 2-bit integer). | |
| 945 | 			public readonly ordinal: int, | |
| 946 | 			// (Package-private) In the range 0 to 3 (unsigned 2-bit integer). | |
| 947 | 			public readonly formatBits: int) {} | |
| 948 | 		 | |
| 949 | 	} | |
| 950 | } | |
| 951 | ||
| 952 | ||
| 953 | ||
| 954 | /*---- Public helper enumeration ----*/ | |
| 955 | ||
| 956 | namespace qrcodegen.QrSegment { | |
| 957 | 	 | |
| 958 | 	type int = number; | |
| 959 | 	 | |
| 960 | 	 | |
| 961 | 	/* | |
| 962 | 	 * Describes how a segment's data bits are interpreted. Immutable. | |
| 963 | 	 */ | |
| 964 | 	export class Mode { | |
| 965 | 		 | |
| 966 | 		/*-- Constants --*/ | |
| 967 | 		 | |
| 968 | 		public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]); | |
| 969 | 		public static readonly ALPHANUMERIC = new Mode(0x2, [ 9, 11, 13]); | |
| 970 | 		public static readonly BYTE = new Mode(0x4, [ 8, 16, 16]); | |
| 971 | 		public static readonly KANJI = new Mode(0x8, [ 8, 10, 12]); | |
| 972 | 		public static readonly ECI = new Mode(0x7, [ 0, 0, 0]); | |
| 973 | 		 | |
| 974 | 		 | |
| 975 | 		/*-- Constructor and fields --*/ | |
| 976 | 		 | |
| 977 | 		private constructor( | |
| 978 | 			// The mode indicator bits, which is a uint4 value (range 0 to 15). | |
| 979 | 			public readonly modeBits: int, | |
| 980 | 			// Number of character count bits for three different version ranges. | |
| 981 | 			private readonly numBitsCharCount: [int,int,int]) {} | |
| 982 | 		 | |
| 983 | 		 | |
| 984 | 		/*-- Method --*/ | |
| 985 | 		 | |
| 986 | 		// (Package-private) Returns the bit width of the character count field for a segment in | |
| 987 | 		// this mode in a QR Code at the given version number. The result is in the range [0, 16]. | |
| 988 | 		public numCharCountBits(ver: int): int { | |
| 989 | 			return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; | |
| 990 | 		} | |
| 991 | 		 | |
| 992 | 	} | |
| 993 | } | |
| 994 | ||
| 995 | export const QrCode = qrcodegen.QrCode; | |
| 996 | export const QrSegment = qrcodegen.QrSegment; |
apps/web/src/localApi.test.ts+44-22| ... | ... | @@ -79,6 +79,7 @@ const rpcClientMock = { |
| 79 | 79 | updateSettings: vi.fn(), |
| 80 | 80 | subscribeConfig: vi.fn(), |
| 81 | 81 | subscribeLifecycle: vi.fn(), |
| 82 | subscribeAuthAccess: vi.fn(), | |
| 82 | 83 | }, |
| 83 | 84 | orchestration: { |
| 84 | 85 | getSnapshot: vi.fn(), |
| ... | ... | @@ -92,23 +93,29 @@ const rpcClientMock = { |
| 92 | 93 | }, |
| 93 | 94 | }; |
| 94 | 95 | |
| 95 | vi.mock("./wsRpcClient", () => { | |
| 96 | return { | |
| 97 | getWsRpcClient: () => rpcClientMock, | |
| 98 | getPrimaryWsRpcClientEntry: () => ({ | |
| 99 | key: "primary", | |
| 100 | knownEnvironment: { | |
| 101 | id: "primary", | |
| 102 | label: "Primary", | |
| 103 | source: "manual", | |
| 104 | target: { type: "ws", wsUrl: "ws://localhost:3000" }, | |
| 96 | vi.mock("./environments/runtime", () => ({ | |
| 97 | getPrimaryEnvironmentConnection: () => ({ | |
| 98 | kind: "primary" as const, | |
| 99 | knownEnvironment: { | |
| 100 | id: "environment-local", | |
| 101 | label: "Primary", | |
| 102 | source: "manual" as const, | |
| 103 | target: { | |
| 104 | httpBaseUrl: "http://localhost:3000", | |
| 105 | wsBaseUrl: "ws://localhost:3000", | |
| 105 | 106 | }, |
| 106 | client: rpcClientMock, | |
| 107 | environmentId: null, | |
| 108 | }), | |
| 109 | __resetWsRpcClientForTests: vi.fn(), | |
| 110 | }; | |
| 111 | }); | |
| 107 | environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 108 | }, | |
| 109 | client: rpcClientMock, | |
| 110 | environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 111 | ensureBootstrapped: async () => undefined, | |
| 112 | reconnect: async () => undefined, | |
| 113 | dispose: async () => undefined, | |
| 114 | }), | |
| 115 | resetEnvironmentServiceForTests: vi.fn(), | |
| 116 | resetSavedEnvironmentRegistryStoreForTests: vi.fn(), | |
| 117 | resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), | |
| 118 | })); | |
| 112 | 119 | |
| 113 | 120 | vi.mock("./contextMenuFallback", () => ({ |
| 114 | 121 | showContextMenuFallback: showContextMenuFallbackMock, |
| ... | ... | @@ -132,8 +139,17 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn |
| 132 | 139 | |
| 133 | 140 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { |
| 134 | 141 | return { |
| 135 | getWsUrl: () => null, | |
| 136 | 142 | getLocalEnvironmentBootstrap: () => null, |
| 143 | getServerExposureState: async () => ({ | |
| 144 | mode: "local-only", | |
| 145 | endpointUrl: null, | |
| 146 | advertisedHost: null, | |
| 147 | }), | |
| 148 | setServerExposureMode: async () => ({ | |
| 149 | mode: "local-only", | |
| 150 | endpointUrl: null, | |
| 151 | advertisedHost: null, | |
| 152 | }), | |
| 137 | 153 | pickFolder: async () => null, |
| 138 | 154 | confirm: async () => true, |
| 139 | 155 | setTheme: async () => undefined, |
| ... | ... | @@ -185,6 +201,12 @@ const baseEnvironment = { |
| 185 | 201 | |
| 186 | 202 | const baseServerConfig: ServerConfig = { |
| 187 | 203 | environment: baseEnvironment, |
| 204 | auth: { | |
| 205 | policy: "loopback-browser", | |
| 206 | bootstrapMethods: ["one-time-token"], | |
| 207 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 208 | sessionCookieName: "t3_session", | |
| 209 | }, | |
| 188 | 210 | cwd: "/tmp/workspace", |
| 189 | 211 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", |
| 190 | 212 | keybindings: [], |
| ... | ... | @@ -232,7 +254,7 @@ describe("wsApi", () => { |
| 232 | 254 | rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); |
| 233 | 255 | const { createLocalApi } = await import("./localApi"); |
| 234 | 256 | |
| 235 | const api = createLocalApi(); | |
| 257 | const api = createLocalApi(rpcClientMock as never); | |
| 236 | 258 | |
| 237 | 259 | await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); |
| 238 | 260 | expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); |
| ... | ... | @@ -395,7 +417,7 @@ describe("wsApi", () => { |
| 395 | 417 | rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); |
| 396 | 418 | const { createLocalApi } = await import("./localApi"); |
| 397 | 419 | |
| 398 | const api = createLocalApi(); | |
| 420 | const api = createLocalApi(rpcClientMock as never); | |
| 399 | 421 | |
| 400 | 422 | await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); |
| 401 | 423 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); |
| ... | ... | @@ -409,7 +431,7 @@ describe("wsApi", () => { |
| 409 | 431 | rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); |
| 410 | 432 | const { createLocalApi } = await import("./localApi"); |
| 411 | 433 | |
| 412 | const api = createLocalApi(); | |
| 434 | const api = createLocalApi(rpcClientMock as never); | |
| 413 | 435 | |
| 414 | 436 | await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( |
| 415 | 437 | nextSettings, |
| ... | ... | @@ -424,7 +446,7 @@ describe("wsApi", () => { |
| 424 | 446 | getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); |
| 425 | 447 | |
| 426 | 448 | const { createLocalApi } = await import("./localApi"); |
| 427 | const api = createLocalApi(); | |
| 449 | const api = createLocalApi(rpcClientMock as never); | |
| 428 | 450 | const items = [{ id: "delete", label: "Delete" }] as const; |
| 429 | 451 | |
| 430 | 452 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); |
| ... | ... | @@ -435,7 +457,7 @@ describe("wsApi", () => { |
| 435 | 457 | showContextMenuFallbackMock.mockResolvedValue("rename"); |
| 436 | 458 | const { createLocalApi } = await import("./localApi"); |
| 437 | 459 | |
| 438 | const api = createLocalApi(); | |
| 460 | const api = createLocalApi(rpcClientMock as never); | |
| 439 | 461 | const items = [{ id: "rename", label: "Rename" }] as const; |
| 440 | 462 | |
| 441 | 463 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); |
apps/web/src/localApi.ts+14-9| ... | ... | @@ -1,19 +1,23 @@ |
| 1 | 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | 3 | import { resetGitStatusStateForTests } from "./lib/gitStatusState"; |
| 4 | ||
| 5 | import { __resetWsRpcAtomClientForTests } from "./rpc/client"; | |
| 6 | 4 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; |
| 7 | 5 | import { resetServerStateForTests } from "./rpc/serverState"; |
| 8 | 6 | import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; |
| 9 | import { getPrimaryWsRpcClientEntry, WsRpcClient, __resetWsRpcClientForTests } from "./wsRpcClient"; | |
| 7 | import { | |
| 8 | resetSavedEnvironmentRegistryStoreForTests, | |
| 9 | resetSavedEnvironmentRuntimeStoreForTests, | |
| 10 | } from "./environments/runtime"; | |
| 11 | import { | |
| 12 | getPrimaryEnvironmentConnection, | |
| 13 | resetEnvironmentServiceForTests, | |
| 14 | } from "./environments/runtime"; | |
| 15 | import { type WsRpcClient } from "./rpc/wsRpcClient"; | |
| 10 | 16 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 11 | 17 | |
| 12 | 18 | let cachedApi: LocalApi | undefined; |
| 13 | 19 | |
| 14 | export function createLocalApi( | |
| 15 | rpcClient: WsRpcClient = getPrimaryWsRpcClientEntry().client, | |
| 16 | ): LocalApi { | |
| 20 | export function createLocalApi(rpcClient: WsRpcClient): LocalApi { | |
| 17 | 21 | return { |
| 18 | 22 | dialogs: { |
| 19 | 23 | pickFolder: async () => { |
| ... | ... | @@ -71,7 +75,7 @@ export function readLocalApi(): LocalApi | undefined { |
| 71 | 75 | return cachedApi; |
| 72 | 76 | } |
| 73 | 77 | |
| 74 | cachedApi = createLocalApi(); | |
| 78 | cachedApi = createLocalApi(getPrimaryEnvironmentConnection().client); | |
| 75 | 79 | return cachedApi; |
| 76 | 80 | } |
| 77 | 81 | |
| ... | ... | @@ -85,10 +89,11 @@ export function ensureLocalApi(): LocalApi { |
| 85 | 89 | |
| 86 | 90 | export async function __resetLocalApiForTests() { |
| 87 | 91 | cachedApi = undefined; |
| 88 | await __resetWsRpcAtomClientForTests(); | |
| 89 | await __resetWsRpcClientForTests(); | |
| 92 | await resetEnvironmentServiceForTests(); | |
| 90 | 93 | resetGitStatusStateForTests(); |
| 91 | 94 | resetRequestLatencyStateForTests(); |
| 95 | resetSavedEnvironmentRegistryStoreForTests(); | |
| 96 | resetSavedEnvironmentRuntimeStoreForTests(); | |
| 92 | 97 | resetServerStateForTests(); |
| 93 | 98 | resetWsConnectionStateForTests(); |
| 94 | 99 | } |
apps/web/src/observability/clientTracing.ts+2-5| ... | ... | @@ -2,8 +2,8 @@ import { Exit, Layer, ManagedRuntime, Scope, Tracer } from "effect"; |
| 2 | 2 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; |
| 3 | 3 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 4 | 4 | |
| 5 | import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; | |
| 5 | 6 | import { isElectron } from "../env"; |
| 6 | import { resolveServerUrl } from "../lib/utils"; | |
| 7 | 7 | import { APP_VERSION } from "~/branding"; |
| 8 | 8 | |
| 9 | 9 | const DEFAULT_EXPORT_INTERVAL_MS = 1_000; |
| ... | ... | @@ -51,10 +51,7 @@ export function configureClientTracing(config: ClientTracingConfig = {}): Promis |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | 53 | async function applyClientTracingConfig(config: ClientTracingConfig): Promise<void> { |
| 54 | const otlpTracesUrl = resolveServerUrl({ | |
| 55 | protocol: window.location.protocol === "https:" ? "https" : "http", | |
| 56 | pathname: "/api/observability/v1/traces", | |
| 57 | }); | |
| 54 | const otlpTracesUrl = resolvePrimaryEnvironmentHttpUrl("/api/observability/v1/traces"); | |
| 58 | 55 | const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS); |
| 59 | 56 | const nextConfigKey = `${otlpTracesUrl}|${exportIntervalMs}`; |
| 60 | 57 |
apps/web/src/pairingUrl.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | const PAIRING_TOKEN_PARAM = "token"; | |
| 2 | ||
| 3 | function readHashParams(url: URL): URLSearchParams { | |
| 4 | return new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); | |
| 5 | } | |
| 6 | ||
| 7 | export function getPairingTokenFromUrl(url: URL): string | null { | |
| 8 | const hashToken = readHashParams(url).get(PAIRING_TOKEN_PARAM)?.trim() ?? ""; | |
| 9 | if (hashToken.length > 0) { | |
| 10 | return hashToken; | |
| 11 | } | |
| 12 | ||
| 13 | const searchToken = url.searchParams.get(PAIRING_TOKEN_PARAM)?.trim() ?? ""; | |
| 14 | return searchToken.length > 0 ? searchToken : null; | |
| 15 | } | |
| 16 | ||
| 17 | export function stripPairingTokenFromUrl(url: URL): URL { | |
| 18 | const next = new URL(url.toString()); | |
| 19 | const hashParams = readHashParams(next); | |
| 20 | if (hashParams.has(PAIRING_TOKEN_PARAM)) { | |
| 21 | hashParams.delete(PAIRING_TOKEN_PARAM); | |
| 22 | next.hash = hashParams.toString(); | |
| 23 | } | |
| 24 | next.searchParams.delete(PAIRING_TOKEN_PARAM); | |
| 25 | return next; | |
| 26 | } | |
| 27 | ||
| 28 | export function setPairingTokenOnUrl(url: URL, credential: string): URL { | |
| 29 | const next = new URL(url.toString()); | |
| 30 | next.searchParams.delete(PAIRING_TOKEN_PARAM); | |
| 31 | next.hash = new URLSearchParams([[PAIRING_TOKEN_PARAM, credential]]).toString(); | |
| 32 | return next; | |
| 33 | } |
apps/web/src/routeTree.gen.ts+42| ... | ... | @@ -10,9 +10,11 @@ |
| 10 | 10 | |
| 11 | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | 12 | import { Route as SettingsRouteImport } from './routes/settings' |
| 13 | import { Route as PairRouteImport } from './routes/pair' | |
| 13 | 14 | import { Route as ChatRouteImport } from './routes/_chat' |
| 14 | 15 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 15 | 16 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 17 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' | |
| 16 | 18 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| 17 | 19 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' |
| 18 | 20 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' |
| ... | ... | @@ -22,6 +24,11 @@ const SettingsRoute = SettingsRouteImport.update({ |
| 22 | 24 | path: '/settings', |
| 23 | 25 | getParentRoute: () => rootRouteImport, |
| 24 | 26 | } as any) |
| 27 | const PairRoute = PairRouteImport.update({ | |
| 28 | id: '/pair', | |
| 29 | path: '/pair', | |
| 30 | getParentRoute: () => rootRouteImport, | |
| 31 | } as any) | |
| 25 | 32 | const ChatRoute = ChatRouteImport.update({ |
| 26 | 33 | id: '/_chat', |
| 27 | 34 | getParentRoute: () => rootRouteImport, |
| ... | ... | @@ -36,6 +43,11 @@ const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ |
| 36 | 43 | path: '/general', |
| 37 | 44 | getParentRoute: () => SettingsRoute, |
| 38 | 45 | } as any) |
| 46 | const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ | |
| 47 | id: '/connections', | |
| 48 | path: '/connections', | |
| 49 | getParentRoute: () => SettingsRoute, | |
| 50 | } as any) | |
| 39 | 51 | const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ |
| 40 | 52 | id: '/archived', |
| 41 | 53 | path: '/archived', |
| ... | ... | @@ -55,15 +67,19 @@ const ChatEnvironmentIdThreadIdRoute = |
| 55 | 67 | |
| 56 | 68 | export interface FileRoutesByFullPath { |
| 57 | 69 | '/': typeof ChatIndexRoute |
| 70 | '/pair': typeof PairRoute | |
| 58 | 71 | '/settings': typeof SettingsRouteWithChildren |
| 59 | 72 | '/settings/archived': typeof SettingsArchivedRoute |
| 73 | '/settings/connections': typeof SettingsConnectionsRoute | |
| 60 | 74 | '/settings/general': typeof SettingsGeneralRoute |
| 61 | 75 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 62 | 76 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| 63 | 77 | } |
| 64 | 78 | export interface FileRoutesByTo { |
| 79 | '/pair': typeof PairRoute | |
| 65 | 80 | '/settings': typeof SettingsRouteWithChildren |
| 66 | 81 | '/settings/archived': typeof SettingsArchivedRoute |
| 82 | '/settings/connections': typeof SettingsConnectionsRoute | |
| 67 | 83 | '/settings/general': typeof SettingsGeneralRoute |
| 68 | 84 | '/': typeof ChatIndexRoute |
| 69 | 85 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| ... | ... | @@ -72,8 +88,10 @@ export interface FileRoutesByTo { |
| 72 | 88 | export interface FileRoutesById { |
| 73 | 89 | __root__: typeof rootRouteImport |
| 74 | 90 | '/_chat': typeof ChatRouteWithChildren |
| 91 | '/pair': typeof PairRoute | |
| 75 | 92 | '/settings': typeof SettingsRouteWithChildren |
| 76 | 93 | '/settings/archived': typeof SettingsArchivedRoute |
| 94 | '/settings/connections': typeof SettingsConnectionsRoute | |
| 77 | 95 | '/settings/general': typeof SettingsGeneralRoute |
| 78 | 96 | '/_chat/': typeof ChatIndexRoute |
| 79 | 97 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| ... | ... | @@ -83,15 +101,19 @@ export interface FileRouteTypes { |
| 83 | 101 | fileRoutesByFullPath: FileRoutesByFullPath |
| 84 | 102 | fullPaths: |
| 85 | 103 | | '/' |
| 104 | | '/pair' | |
| 86 | 105 | | '/settings' |
| 87 | 106 | | '/settings/archived' |
| 107 | | '/settings/connections' | |
| 88 | 108 | | '/settings/general' |
| 89 | 109 | | '/$environmentId/$threadId' |
| 90 | 110 | | '/draft/$draftId' |
| 91 | 111 | fileRoutesByTo: FileRoutesByTo |
| 92 | 112 | to: |
| 113 | | '/pair' | |
| 93 | 114 | | '/settings' |
| 94 | 115 | | '/settings/archived' |
| 116 | | '/settings/connections' | |
| 95 | 117 | | '/settings/general' |
| 96 | 118 | | '/' |
| 97 | 119 | | '/$environmentId/$threadId' |
| ... | ... | @@ -99,8 +121,10 @@ export interface FileRouteTypes { |
| 99 | 121 | id: |
| 100 | 122 | | '__root__' |
| 101 | 123 | | '/_chat' |
| 124 | | '/pair' | |
| 102 | 125 | | '/settings' |
| 103 | 126 | | '/settings/archived' |
| 127 | | '/settings/connections' | |
| 104 | 128 | | '/settings/general' |
| 105 | 129 | | '/_chat/' |
| 106 | 130 | | '/_chat/$environmentId/$threadId' |
| ... | ... | @@ -109,6 +133,7 @@ export interface FileRouteTypes { |
| 109 | 133 | } |
| 110 | 134 | export interface RootRouteChildren { |
| 111 | 135 | ChatRoute: typeof ChatRouteWithChildren |
| 136 | PairRoute: typeof PairRoute | |
| 112 | 137 | SettingsRoute: typeof SettingsRouteWithChildren |
| 113 | 138 | } |
| 114 | 139 | |
| ... | ... | @@ -121,6 +146,13 @@ declare module '@tanstack/react-router' { |
| 121 | 146 | preLoaderRoute: typeof SettingsRouteImport |
| 122 | 147 | parentRoute: typeof rootRouteImport |
| 123 | 148 | } |
| 149 | '/pair': { | |
| 150 | id: '/pair' | |
| 151 | path: '/pair' | |
| 152 | fullPath: '/pair' | |
| 153 | preLoaderRoute: typeof PairRouteImport | |
| 154 | parentRoute: typeof rootRouteImport | |
| 155 | } | |
| 124 | 156 | '/_chat': { |
| 125 | 157 | id: '/_chat' |
| 126 | 158 | path: '' |
| ... | ... | @@ -142,6 +174,13 @@ declare module '@tanstack/react-router' { |
| 142 | 174 | preLoaderRoute: typeof SettingsGeneralRouteImport |
| 143 | 175 | parentRoute: typeof SettingsRoute |
| 144 | 176 | } |
| 177 | '/settings/connections': { | |
| 178 | id: '/settings/connections' | |
| 179 | path: '/connections' | |
| 180 | fullPath: '/settings/connections' | |
| 181 | preLoaderRoute: typeof SettingsConnectionsRouteImport | |
| 182 | parentRoute: typeof SettingsRoute | |
| 183 | } | |
| 145 | 184 | '/settings/archived': { |
| 146 | 185 | id: '/settings/archived' |
| 147 | 186 | path: '/archived' |
| ... | ... | @@ -182,11 +221,13 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) |
| 182 | 221 | |
| 183 | 222 | interface SettingsRouteChildren { |
| 184 | 223 | SettingsArchivedRoute: typeof SettingsArchivedRoute |
| 224 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute | |
| 185 | 225 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 186 | 226 | } |
| 187 | 227 | |
| 188 | 228 | const SettingsRouteChildren: SettingsRouteChildren = { |
| 189 | 229 | SettingsArchivedRoute: SettingsArchivedRoute, |
| 230 | SettingsConnectionsRoute: SettingsConnectionsRoute, | |
| 190 | 231 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 191 | 232 | } |
| 192 | 233 | |
| ... | ... | @@ -196,6 +237,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( |
| 196 | 237 | |
| 197 | 238 | const rootRouteChildren: RootRouteChildren = { |
| 198 | 239 | ChatRoute: ChatRouteWithChildren, |
| 240 | PairRoute: PairRoute, | |
| 199 | 241 | SettingsRoute: SettingsRouteWithChildren, |
| 200 | 242 | } |
| 201 | 243 | export const routeTree = rootRouteImport |
apps/web/src/routes/__root.tsx+70-486| ... | ... | @@ -1,25 +1,14 @@ |
| 1 | import { | |
| 2 | type EnvironmentId, | |
| 3 | OrchestrationEvent, | |
| 4 | type ServerLifecycleWelcomePayload, | |
| 5 | ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { | |
| 8 | scopedProjectKey, | |
| 9 | scopedThreadKey, | |
| 10 | scopeProjectRef, | |
| 11 | scopeThreadRef, | |
| 12 | } from "@t3tools/client-runtime"; | |
| 1 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; | |
| 2 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; | |
| 13 | 3 | import { |
| 14 | 4 | Outlet, |
| 15 | 5 | createRootRouteWithContext, |
| 16 | 6 | type ErrorComponentProps, |
| 17 | useNavigate, | |
| 18 | 7 | useLocation, |
| 8 | useNavigate, | |
| 19 | 9 | } from "@tanstack/react-router"; |
| 20 | import { useEffect, useEffectEvent, useRef, useState } from "react"; | |
| 10 | import { useEffect, useEffectEvent, useRef } from "react"; | |
| 21 | 11 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 22 | import { Throttler } from "@tanstack/react-pacer"; | |
| 23 | 12 | |
| 24 | 13 | import { APP_DISPLAY_NAME } from "../branding"; |
| 25 | 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| ... | ... | @@ -40,38 +29,34 @@ import { |
| 40 | 29 | useServerConfigUpdatedSubscription, |
| 41 | 30 | useServerWelcomeSubscription, |
| 42 | 31 | } from "../rpc/serverState"; |
| 43 | import { | |
| 44 | markPromotedDraftThreadByRef, | |
| 45 | markPromotedDraftThreadsByRef, | |
| 46 | useComposerDraftStore, | |
| 47 | } from "../composerDraftStore"; | |
| 48 | import { | |
| 49 | selectProjectsAcrossEnvironments, | |
| 50 | selectThreadsAcrossEnvironments, | |
| 51 | useStore, | |
| 52 | } from "../store"; | |
| 32 | import { useStore } from "../store"; | |
| 53 | 33 | import { useUiStateStore } from "../uiStateStore"; |
| 54 | import { useTerminalStateStore } from "../terminalStateStore"; | |
| 55 | 34 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; |
| 56 | import { providerQueryKeys } from "../lib/providerReactQuery"; | |
| 57 | import { projectQueryKeys } from "../lib/projectReactQuery"; | |
| 58 | import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; | |
| 59 | import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; | |
| 60 | import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; | |
| 61 | import { deriveReplayRetryDecision } from "../orchestrationRecovery"; | |
| 62 | import { selectThreadByRef } from "../store"; | |
| 35 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; | |
| 36 | import { | |
| 37 | ensureEnvironmentConnectionBootstrapped, | |
| 38 | getPrimaryEnvironmentConnection, | |
| 39 | startEnvironmentConnectionService, | |
| 40 | } from "../environments/runtime"; | |
| 41 | import { configureClientTracing } from "../observability/clientTracing"; | |
| 63 | 42 | import { |
| 64 | bindPrimaryWsRpcClientEnvironment, | |
| 65 | bindWsRpcClientEntryEnvironment, | |
| 66 | getPrimaryWsRpcClientEntry, | |
| 67 | listWsRpcClientEntries, | |
| 68 | subscribeWsRpcClientRegistry, | |
| 69 | type WsRpcClientEntry, | |
| 70 | } from "~/wsRpcClient"; | |
| 43 | ensurePrimaryEnvironmentReady, | |
| 44 | resolveInitialServerAuthGateState, | |
| 45 | updatePrimaryEnvironmentDescriptor, | |
| 46 | } from "../environments/primary"; | |
| 71 | 47 | |
| 72 | 48 | export const Route = createRootRouteWithContext<{ |
| 73 | 49 | queryClient: QueryClient; |
| 74 | 50 | }>()({ |
| 51 | beforeLoad: async () => { | |
| 52 | const [, authGateState] = await Promise.all([ | |
| 53 | ensurePrimaryEnvironmentReady(), | |
| 54 | resolveInitialServerAuthGateState(), | |
| 55 | ]); | |
| 56 | return { | |
| 57 | authGateState, | |
| 58 | }; | |
| 59 | }, | |
| 75 | 60 | component: RootRouteView, |
| 76 | 61 | errorComponent: RootRouteErrorView, |
| 77 | 62 | head: () => ({ |
| ... | ... | @@ -80,22 +65,31 @@ export const Route = createRootRouteWithContext<{ |
| 80 | 65 | }); |
| 81 | 66 | |
| 82 | 67 | function RootRouteView() { |
| 83 | if (!readLocalApi()) { | |
| 84 | return ( | |
| 85 | <div className="flex h-screen flex-col bg-background text-foreground"> | |
| 86 | <div className="flex flex-1 items-center justify-center"> | |
| 87 | <p className="text-sm text-muted-foreground"> | |
| 88 | Connecting to {APP_DISPLAY_NAME} server... | |
| 89 | </p> | |
| 90 | </div> | |
| 91 | </div> | |
| 92 | ); | |
| 68 | const pathname = useLocation({ select: (location) => location.pathname }); | |
| 69 | const { authGateState } = Route.useRouteContext(); | |
| 70 | ||
| 71 | useEffect(() => { | |
| 72 | const frame = window.requestAnimationFrame(() => { | |
| 73 | syncBrowserChromeTheme(); | |
| 74 | }); | |
| 75 | return () => { | |
| 76 | window.cancelAnimationFrame(frame); | |
| 77 | }; | |
| 78 | }, [pathname]); | |
| 79 | ||
| 80 | if (pathname === "/pair") { | |
| 81 | return <Outlet />; | |
| 93 | 82 | } |
| 94 | 83 | |
| 84 | if (authGateState.status !== "authenticated") { | |
| 85 | return <Outlet />; | |
| 86 | } | |
| 95 | 87 | return ( |
| 96 | 88 | <ToastProvider> |
| 97 | 89 | <AnchoredToastProvider> |
| 90 | <AuthenticatedTracingBootstrap /> | |
| 98 | 91 | <ServerStateBootstrap /> |
| 92 | <EnvironmentConnectionManagerBootstrap /> | |
| 99 | 93 | <EventRouter /> |
| 100 | 94 | <WebSocketConnectionCoordinator /> |
| 101 | 95 | <SlowRpcAckToastCoordinator /> |
| ... | ... | @@ -180,97 +174,48 @@ function errorDetails(error: unknown): string { |
| 180 | 174 | } |
| 181 | 175 | } |
| 182 | 176 | |
| 183 | function coalesceOrchestrationUiEvents( | |
| 184 | events: ReadonlyArray<OrchestrationEvent>, | |
| 185 | ): OrchestrationEvent[] { | |
| 186 | if (events.length < 2) { | |
| 187 | return [...events]; | |
| 188 | } | |
| 189 | ||
| 190 | const coalesced: OrchestrationEvent[] = []; | |
| 191 | for (const event of events) { | |
| 192 | const previous = coalesced.at(-1); | |
| 193 | if ( | |
| 194 | previous?.type === "thread.message-sent" && | |
| 195 | event.type === "thread.message-sent" && | |
| 196 | previous.payload.threadId === event.payload.threadId && | |
| 197 | previous.payload.messageId === event.payload.messageId | |
| 198 | ) { | |
| 199 | coalesced[coalesced.length - 1] = { | |
| 200 | ...event, | |
| 201 | payload: { | |
| 202 | ...event.payload, | |
| 203 | attachments: event.payload.attachments ?? previous.payload.attachments, | |
| 204 | createdAt: previous.payload.createdAt, | |
| 205 | text: | |
| 206 | !event.payload.streaming && event.payload.text.length > 0 | |
| 207 | ? event.payload.text | |
| 208 | : previous.payload.text + event.payload.text, | |
| 209 | }, | |
| 210 | }; | |
| 211 | continue; | |
| 212 | } | |
| 213 | ||
| 214 | coalesced.push(event); | |
| 215 | } | |
| 177 | function ServerStateBootstrap() { | |
| 178 | useEffect(() => startServerStateSync(getPrimaryEnvironmentConnection().client.server), []); | |
| 216 | 179 | |
| 217 | return coalesced; | |
| 180 | return null; | |
| 218 | 181 | } |
| 219 | 182 | |
| 220 | const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; | |
| 221 | const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; | |
| 222 | ||
| 223 | function useRegisteredWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> { | |
| 224 | const [, setRevision] = useState(0); | |
| 225 | ||
| 226 | useEffect(() => subscribeWsRpcClientRegistry(() => setRevision((value) => value + 1)), []); | |
| 183 | function AuthenticatedTracingBootstrap() { | |
| 184 | useEffect(() => { | |
| 185 | void configureClientTracing(); | |
| 186 | }, []); | |
| 227 | 187 | |
| 228 | const entries = listWsRpcClientEntries(); | |
| 229 | return entries.length > 0 ? entries : [getPrimaryWsRpcClientEntry()]; | |
| 188 | return null; | |
| 230 | 189 | } |
| 231 | 190 | |
| 232 | function ServerStateBootstrap() { | |
| 233 | useEffect(() => startServerStateSync(getPrimaryWsRpcClientEntry().client.server), []); | |
| 191 | function EnvironmentConnectionManagerBootstrap() { | |
| 192 | const queryClient = useQueryClient(); | |
| 193 | ||
| 194 | useEffect(() => { | |
| 195 | return startEnvironmentConnectionService(queryClient); | |
| 196 | }, [queryClient]); | |
| 234 | 197 | |
| 235 | 198 | return null; |
| 236 | 199 | } |
| 237 | 200 | |
| 238 | 201 | function EventRouter() { |
| 239 | const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); | |
| 240 | 202 | const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); |
| 241 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); | |
| 242 | const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); | |
| 243 | const syncProjects = useUiStateStore((store) => store.syncProjects); | |
| 244 | const syncThreads = useUiStateStore((store) => store.syncThreads); | |
| 245 | const clearThreadUi = useUiStateStore((store) => store.clearThreadUi); | |
| 246 | const removeTerminalState = useTerminalStateStore((store) => store.removeTerminalState); | |
| 247 | const removeOrphanedTerminalStates = useTerminalStateStore( | |
| 248 | (store) => store.removeOrphanedTerminalStates, | |
| 249 | ); | |
| 250 | const applyTerminalEvent = useTerminalStateStore((store) => store.applyTerminalEvent); | |
| 251 | const queryClient = useQueryClient(); | |
| 252 | 203 | const navigate = useNavigate(); |
| 253 | 204 | const pathname = useLocation({ select: (loc) => loc.pathname }); |
| 254 | 205 | const readPathname = useEffectEvent(() => pathname); |
| 255 | 206 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 256 | 207 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); |
| 257 | 208 | const disposedRef = useRef(false); |
| 258 | const bootstrapFromSnapshotRef = useRef<(environmentId: EnvironmentId) => Promise<void>>( | |
| 259 | async () => undefined, | |
| 260 | ); | |
| 261 | const schedulePendingDomainEventFlushRef = useRef<() => void>(() => undefined); | |
| 262 | 209 | const serverConfig = useServerConfig(); |
| 263 | const clientEntries = useRegisteredWsRpcClientEntries(); | |
| 264 | 210 | |
| 265 | 211 | const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { |
| 266 | 212 | if (!payload) return; |
| 267 | 213 | |
| 268 | bindPrimaryWsRpcClientEnvironment(payload.environment.environmentId); | |
| 214 | updatePrimaryEnvironmentDescriptor(payload.environment); | |
| 269 | 215 | setActiveEnvironmentId(payload.environment.environmentId); |
| 270 | schedulePendingDomainEventFlushRef.current(); | |
| 271 | 216 | migrateLocalSettingsToServer(); |
| 272 | 217 | void (async () => { |
| 273 | await bootstrapFromSnapshotRef.current(payload.environment.environmentId); | |
| 218 | await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); | |
| 274 | 219 | if (disposedRef.current) { |
| 275 | 220 | return; |
| 276 | 221 | } |
| ... | ... | @@ -278,12 +223,14 @@ function EventRouter() { |
| 278 | 223 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { |
| 279 | 224 | return; |
| 280 | 225 | } |
| 281 | setProjectExpanded( | |
| 282 | scopedProjectKey( | |
| 283 | scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), | |
| 284 | ), | |
| 285 | true, | |
| 286 | ); | |
| 226 | useUiStateStore | |
| 227 | .getState() | |
| 228 | .setProjectExpanded( | |
| 229 | scopedProjectKey( | |
| 230 | scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), | |
| 231 | ), | |
| 232 | true, | |
| 233 | ); | |
| 287 | 234 | |
| 288 | 235 | if (readPathname() !== "/") { |
| 289 | 236 | return; |
| ... | ... | @@ -365,379 +312,16 @@ function EventRouter() { |
| 365 | 312 | return; |
| 366 | 313 | } |
| 367 | 314 | |
| 368 | bindPrimaryWsRpcClientEnvironment(serverConfig.environment.environmentId); | |
| 315 | updatePrimaryEnvironmentDescriptor(serverConfig.environment); | |
| 369 | 316 | setActiveEnvironmentId(serverConfig.environment.environmentId); |
| 370 | schedulePendingDomainEventFlushRef.current(); | |
| 371 | 317 | }, [serverConfig, setActiveEnvironmentId]); |
| 372 | 318 | |
| 373 | 319 | useEffect(() => { |
| 374 | let disposed = false; | |
| 375 | 320 | disposedRef.current = false; |
| 376 | let needsProviderInvalidation = false; | |
| 377 | const primaryClientKey = getPrimaryWsRpcClientEntry().key; | |
| 378 | ||
| 379 | const reconcileSnapshotDerivedState = () => { | |
| 380 | const storeState = useStore.getState(); | |
| 381 | const threads = selectThreadsAcrossEnvironments(storeState); | |
| 382 | const projects = selectProjectsAcrossEnvironments(storeState); | |
| 383 | syncProjects( | |
| 384 | projects.map((project) => ({ | |
| 385 | key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 386 | cwd: project.cwd, | |
| 387 | })), | |
| 388 | ); | |
| 389 | syncThreads( | |
| 390 | threads.map((thread) => ({ | |
| 391 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 392 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, | |
| 393 | })), | |
| 394 | ); | |
| 395 | markPromotedDraftThreadsByRef( | |
| 396 | threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), | |
| 397 | ); | |
| 398 | const activeThreadKeys = collectActiveTerminalThreadIds({ | |
| 399 | snapshotThreads: threads.map((thread) => ({ | |
| 400 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 401 | deletedAt: null, | |
| 402 | archivedAt: thread.archivedAt, | |
| 403 | })), | |
| 404 | draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), | |
| 405 | }); | |
| 406 | removeOrphanedTerminalStates(activeThreadKeys); | |
| 407 | }; | |
| 408 | ||
| 409 | const queryInvalidationThrottler = new Throttler( | |
| 410 | () => { | |
| 411 | if (!needsProviderInvalidation) { | |
| 412 | return; | |
| 413 | } | |
| 414 | needsProviderInvalidation = false; | |
| 415 | void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); | |
| 416 | // Invalidate workspace entry queries so the @-mention file picker | |
| 417 | // reflects files created, deleted, or restored during this turn. | |
| 418 | void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all }); | |
| 419 | }, | |
| 420 | { | |
| 421 | wait: 100, | |
| 422 | leading: false, | |
| 423 | trailing: true, | |
| 424 | }, | |
| 425 | ); | |
| 426 | ||
| 427 | const applyEventBatch = ( | |
| 428 | events: ReadonlyArray<OrchestrationEvent>, | |
| 429 | environmentId: EnvironmentId, | |
| 430 | recovery: ReturnType<typeof createOrchestrationRecoveryCoordinator>, | |
| 431 | ) => { | |
| 432 | const nextEvents = recovery.markEventBatchApplied(events); | |
| 433 | if (nextEvents.length === 0) { | |
| 434 | return; | |
| 435 | } | |
| 436 | ||
| 437 | const batchEffects = deriveOrchestrationBatchEffects(nextEvents); | |
| 438 | const uiEvents = coalesceOrchestrationUiEvents(nextEvents); | |
| 439 | const needsProjectUiSync = nextEvents.some( | |
| 440 | (event) => | |
| 441 | event.type === "project.created" || | |
| 442 | event.type === "project.meta-updated" || | |
| 443 | event.type === "project.deleted", | |
| 444 | ); | |
| 445 | ||
| 446 | if (batchEffects.needsProviderInvalidation) { | |
| 447 | needsProviderInvalidation = true; | |
| 448 | void queryInvalidationThrottler.maybeExecute(); | |
| 449 | } | |
| 450 | ||
| 451 | applyOrchestrationEvents(uiEvents, environmentId); | |
| 452 | if (needsProjectUiSync) { | |
| 453 | const projects = selectProjectsAcrossEnvironments(useStore.getState()); | |
| 454 | syncProjects( | |
| 455 | projects.map((project) => ({ | |
| 456 | key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 457 | cwd: project.cwd, | |
| 458 | })), | |
| 459 | ); | |
| 460 | } | |
| 461 | const needsThreadUiSync = nextEvents.some( | |
| 462 | (event) => event.type === "thread.created" || event.type === "thread.deleted", | |
| 463 | ); | |
| 464 | if (needsThreadUiSync) { | |
| 465 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); | |
| 466 | syncThreads( | |
| 467 | threads.map((thread) => ({ | |
| 468 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 469 | seedVisitedAt: thread.updatedAt ?? thread.createdAt, | |
| 470 | })), | |
| 471 | ); | |
| 472 | } | |
| 473 | const draftStore = useComposerDraftStore.getState(); | |
| 474 | for (const threadId of batchEffects.promoteDraftThreadIds) { | |
| 475 | markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId)); | |
| 476 | } | |
| 477 | for (const threadId of batchEffects.clearDeletedThreadIds) { | |
| 478 | draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId)); | |
| 479 | clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); | |
| 480 | } | |
| 481 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { | |
| 482 | removeTerminalState(scopeThreadRef(environmentId, threadId)); | |
| 483 | } | |
| 484 | }; | |
| 485 | const clientContexts = clientEntries.map((entry) => { | |
| 486 | const recovery = createOrchestrationRecoveryCoordinator(); | |
| 487 | let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null; | |
| 488 | const pendingDomainEvents: OrchestrationEvent[] = []; | |
| 489 | let flushPendingDomainEventsScheduled = false; | |
| 490 | let boundEnvironmentId = entry.environmentId; | |
| 491 | ||
| 492 | const bindEnvironmentId = (environmentId: EnvironmentId) => { | |
| 493 | if (boundEnvironmentId === environmentId) { | |
| 494 | return; | |
| 495 | } | |
| 496 | boundEnvironmentId = environmentId; | |
| 497 | bindWsRpcClientEntryEnvironment(entry.key, environmentId); | |
| 498 | schedulePendingDomainEventFlush(); | |
| 499 | }; | |
| 500 | ||
| 501 | const flushPendingDomainEvents = () => { | |
| 502 | flushPendingDomainEventsScheduled = false; | |
| 503 | if (disposed || pendingDomainEvents.length === 0 || boundEnvironmentId === null) { | |
| 504 | return; | |
| 505 | } | |
| 506 | ||
| 507 | const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); | |
| 508 | applyEventBatch(events, boundEnvironmentId, recovery); | |
| 509 | }; | |
| 510 | ||
| 511 | const schedulePendingDomainEventFlush = () => { | |
| 512 | if (flushPendingDomainEventsScheduled) { | |
| 513 | return; | |
| 514 | } | |
| 515 | ||
| 516 | flushPendingDomainEventsScheduled = true; | |
| 517 | queueMicrotask(flushPendingDomainEvents); | |
| 518 | }; | |
| 519 | ||
| 520 | const runSnapshotRecovery = async ( | |
| 521 | reason: "bootstrap" | "replay-failed", | |
| 522 | environmentId: EnvironmentId, | |
| 523 | ): Promise<void> => { | |
| 524 | const started = recovery.beginSnapshotRecovery(reason); | |
| 525 | if (import.meta.env.MODE !== "test") { | |
| 526 | const state = recovery.getState(); | |
| 527 | console.info("[orchestration-recovery]", "Snapshot recovery requested.", { | |
| 528 | reason, | |
| 529 | clientKey: entry.key, | |
| 530 | environmentId, | |
| 531 | skipped: !started, | |
| 532 | ...(started | |
| 533 | ? {} | |
| 534 | : { | |
| 535 | blockedBy: state.inFlight?.kind ?? null, | |
| 536 | blockedByReason: state.inFlight?.reason ?? null, | |
| 537 | }), | |
| 538 | state, | |
| 539 | }); | |
| 540 | } | |
| 541 | if (!started) { | |
| 542 | return; | |
| 543 | } | |
| 544 | ||
| 545 | try { | |
| 546 | const snapshot = await entry.client.orchestration.getSnapshot(); | |
| 547 | if (!disposed) { | |
| 548 | bindEnvironmentId(environmentId); | |
| 549 | syncServerReadModel(snapshot, environmentId); | |
| 550 | reconcileSnapshotDerivedState(); | |
| 551 | if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { | |
| 552 | void runReplayRecovery("sequence-gap"); | |
| 553 | } | |
| 554 | } | |
| 555 | } catch { | |
| 556 | recovery.failSnapshotRecovery(); | |
| 557 | } | |
| 558 | }; | |
| 559 | ||
| 560 | const fallbackToSnapshotRecovery = async (): Promise<void> => { | |
| 561 | if (boundEnvironmentId === null) { | |
| 562 | return; | |
| 563 | } | |
| 564 | await runSnapshotRecovery("replay-failed", boundEnvironmentId); | |
| 565 | }; | |
| 566 | ||
| 567 | const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise<void> => { | |
| 568 | if (!recovery.beginReplayRecovery(reason)) { | |
| 569 | return; | |
| 570 | } | |
| 571 | ||
| 572 | const fromSequenceExclusive = recovery.getState().latestSequence; | |
| 573 | try { | |
| 574 | const events = await entry.client.orchestration.replayEvents({ fromSequenceExclusive }); | |
| 575 | if (!disposed) { | |
| 576 | if (boundEnvironmentId === null) { | |
| 577 | replayRetryTracker = null; | |
| 578 | recovery.failReplayRecovery(); | |
| 579 | return; | |
| 580 | } | |
| 581 | applyEventBatch(events, boundEnvironmentId, recovery); | |
| 582 | } | |
| 583 | } catch { | |
| 584 | replayRetryTracker = null; | |
| 585 | recovery.failReplayRecovery(); | |
| 586 | void fallbackToSnapshotRecovery(); | |
| 587 | return; | |
| 588 | } | |
| 589 | ||
| 590 | if (!disposed) { | |
| 591 | const replayCompletion = recovery.completeReplayRecovery(); | |
| 592 | const retryDecision = deriveReplayRetryDecision({ | |
| 593 | previousTracker: replayRetryTracker, | |
| 594 | completion: replayCompletion, | |
| 595 | recoveryState: recovery.getState(), | |
| 596 | baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, | |
| 597 | maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, | |
| 598 | }); | |
| 599 | replayRetryTracker = retryDecision.tracker; | |
| 600 | ||
| 601 | if (retryDecision.shouldRetry) { | |
| 602 | if (retryDecision.delayMs > 0) { | |
| 603 | await new Promise<void>((resolve) => { | |
| 604 | setTimeout(resolve, retryDecision.delayMs); | |
| 605 | }); | |
| 606 | if (disposed) { | |
| 607 | return; | |
| 608 | } | |
| 609 | } | |
| 610 | void runReplayRecovery(reason); | |
| 611 | } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { | |
| 612 | console.warn( | |
| 613 | "[orchestration-recovery]", | |
| 614 | "Stopping replay recovery after no-progress retries.", | |
| 615 | { | |
| 616 | clientKey: entry.key, | |
| 617 | environmentId: boundEnvironmentId, | |
| 618 | state: recovery.getState(), | |
| 619 | }, | |
| 620 | ); | |
| 621 | } | |
| 622 | } | |
| 623 | }; | |
| 624 | ||
| 625 | const unsubLifecycle = entry.client.server.subscribeLifecycle((event) => { | |
| 626 | if (event.type === "welcome") { | |
| 627 | bindEnvironmentId(event.payload.environment.environmentId); | |
| 628 | } | |
| 629 | }); | |
| 630 | const unsubConfig = entry.client.server.subscribeConfig((event) => { | |
| 631 | if (event.type === "snapshot") { | |
| 632 | bindEnvironmentId(event.config.environment.environmentId); | |
| 633 | } | |
| 634 | }); | |
| 635 | if (boundEnvironmentId === null) { | |
| 636 | void entry.client.server | |
| 637 | .getConfig() | |
| 638 | .then((config) => { | |
| 639 | if (!disposed) { | |
| 640 | bindEnvironmentId(config.environment.environmentId); | |
| 641 | } | |
| 642 | }) | |
| 643 | .catch(() => undefined); | |
| 644 | } | |
| 645 | const unsubDomainEvent = entry.client.orchestration.onDomainEvent( | |
| 646 | (event) => { | |
| 647 | const action = recovery.classifyDomainEvent(event.sequence); | |
| 648 | if (action === "apply") { | |
| 649 | pendingDomainEvents.push(event); | |
| 650 | schedulePendingDomainEventFlush(); | |
| 651 | return; | |
| 652 | } | |
| 653 | if (action === "recover") { | |
| 654 | flushPendingDomainEvents(); | |
| 655 | void runReplayRecovery("sequence-gap"); | |
| 656 | } | |
| 657 | }, | |
| 658 | { | |
| 659 | onResubscribe: () => { | |
| 660 | if (disposed) { | |
| 661 | return; | |
| 662 | } | |
| 663 | flushPendingDomainEvents(); | |
| 664 | void runReplayRecovery("resubscribe"); | |
| 665 | }, | |
| 666 | }, | |
| 667 | ); | |
| 668 | const unsubTerminalEvent = entry.client.terminal.onEvent((event) => { | |
| 669 | if (boundEnvironmentId === null) { | |
| 670 | return; | |
| 671 | } | |
| 672 | ||
| 673 | const threadRef = scopeThreadRef(boundEnvironmentId, ThreadId.makeUnsafe(event.threadId)); | |
| 674 | const thread = selectThreadByRef(useStore.getState(), threadRef); | |
| 675 | if (!thread || thread.archivedAt !== null) { | |
| 676 | return; | |
| 677 | } | |
| 678 | applyTerminalEvent(threadRef, event); | |
| 679 | }); | |
| 680 | ||
| 681 | return { | |
| 682 | key: entry.key, | |
| 683 | bindEnvironmentId, | |
| 684 | flushPendingDomainEvents, | |
| 685 | schedulePendingDomainEventFlush, | |
| 686 | runSnapshotRecovery, | |
| 687 | cleanup: () => { | |
| 688 | flushPendingDomainEventsScheduled = false; | |
| 689 | pendingDomainEvents.length = 0; | |
| 690 | unsubDomainEvent(); | |
| 691 | unsubTerminalEvent(); | |
| 692 | unsubLifecycle(); | |
| 693 | unsubConfig(); | |
| 694 | }, | |
| 695 | }; | |
| 696 | }); | |
| 697 | ||
| 698 | schedulePendingDomainEventFlushRef.current = () => { | |
| 699 | for (const context of clientContexts) { | |
| 700 | context.schedulePendingDomainEventFlush(); | |
| 701 | } | |
| 702 | }; | |
| 703 | ||
| 704 | const primaryClientContext = | |
| 705 | clientContexts.find((context) => context.key === primaryClientKey) ?? | |
| 706 | clientContexts[0] ?? | |
| 707 | null; | |
| 708 | bootstrapFromSnapshotRef.current = async (environmentId: EnvironmentId) => { | |
| 709 | if (!primaryClientContext) { | |
| 710 | return; | |
| 711 | } | |
| 712 | primaryClientContext.bindEnvironmentId(environmentId); | |
| 713 | await primaryClientContext.runSnapshotRecovery("bootstrap", environmentId); | |
| 714 | }; | |
| 715 | ||
| 716 | 321 | return () => { |
| 717 | disposed = true; | |
| 718 | 322 | disposedRef.current = true; |
| 719 | needsProviderInvalidation = false; | |
| 720 | schedulePendingDomainEventFlushRef.current = () => undefined; | |
| 721 | queryInvalidationThrottler.cancel(); | |
| 722 | for (const context of clientContexts) { | |
| 723 | context.cleanup(); | |
| 724 | } | |
| 725 | 323 | }; |
| 726 | }, [ | |
| 727 | applyOrchestrationEvents, | |
| 728 | clientEntries, | |
| 729 | navigate, | |
| 730 | queryClient, | |
| 731 | removeTerminalState, | |
| 732 | removeOrphanedTerminalStates, | |
| 733 | applyTerminalEvent, | |
| 734 | clearThreadUi, | |
| 735 | setProjectExpanded, | |
| 736 | setActiveEnvironmentId, | |
| 737 | syncProjects, | |
| 738 | syncServerReadModel, | |
| 739 | syncThreads, | |
| 740 | ]); | |
| 324 | }, []); | |
| 741 | 325 | |
| 742 | 326 | useServerWelcomeSubscription(handleWelcome); |
| 743 | 327 | useServerConfigUpdatedSubscription(handleServerConfigUpdated); |
apps/web/src/routes/_chat.index.tsx+2-26| ... | ... | @@ -1,33 +1,9 @@ |
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | |
| 3 | import { isElectron } from "../env"; | |
| 4 | import { SidebarTrigger } from "../components/ui/sidebar"; | |
| 3 | import { NoActiveThreadState } from "../components/NoActiveThreadState"; | |
| 5 | 4 | |
| 6 | 5 | function ChatIndexRouteView() { |
| 7 | return ( | |
| 8 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40"> | |
| 9 | {!isElectron && ( | |
| 10 | <header className="border-b border-border px-3 py-2 md:hidden"> | |
| 11 | <div className="flex items-center gap-2"> | |
| 12 | <SidebarTrigger className="size-7 shrink-0" /> | |
| 13 | <span className="text-sm font-medium text-foreground">Threads</span> | |
| 14 | </div> | |
| 15 | </header> | |
| 16 | )} | |
| 17 | ||
| 18 | {isElectron && ( | |
| 19 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> | |
| 20 | <span className="text-xs text-muted-foreground/50">No active thread</span> | |
| 21 | </div> | |
| 22 | )} | |
| 23 | ||
| 24 | <div className="flex flex-1 items-center justify-center"> | |
| 25 | <div className="text-center"> | |
| 26 | <p className="text-sm">Select a thread or create a new one to get started.</p> | |
| 27 | </div> | |
| 28 | </div> | |
| 29 | </div> | |
| 30 | ); | |
| 6 | return <NoActiveThreadState />; | |
| 31 | 7 | } |
| 32 | 8 | |
| 33 | 9 | export const Route = createFileRoute("/_chat/")({ |
apps/web/src/routes/_chat.tsx+14-1| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | 1 | import { scopeProjectRef } from "@t3tools/client-runtime"; |
| 2 | import { Outlet, createFileRoute } from "@tanstack/react-router"; | |
| 2 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; | |
| 3 | 3 | import { useEffect } from "react"; |
| 4 | 4 | |
| 5 | import { | |
| 6 | ensurePrimaryEnvironmentReady, | |
| 7 | resolveInitialServerAuthGateState, | |
| 8 | } from "../environments/primary"; | |
| 5 | 9 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 6 | 10 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 7 | 11 | import { resolveShortcutCommand } from "../keybindings"; |
| ... | ... | @@ -102,5 +106,14 @@ function ChatRouteLayout() { |
| 102 | 106 | } |
| 103 | 107 | |
| 104 | 108 | export const Route = createFileRoute("/_chat")({ |
| 109 | beforeLoad: async () => { | |
| 110 | const [, authGateState] = await Promise.all([ | |
| 111 | ensurePrimaryEnvironmentReady(), | |
| 112 | resolveInitialServerAuthGateState(), | |
| 113 | ]); | |
| 114 | if (authGateState.status !== "authenticated") { | |
| 115 | throw redirect({ to: "/pair", replace: true }); | |
| 116 | } | |
| 117 | }, | |
| 105 | 118 | component: ChatRouteLayout, |
| 106 | 119 | }); |
apps/web/src/routes/pair.tsx created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface"; | |
| 4 | import { | |
| 5 | ensurePrimaryEnvironmentReady, | |
| 6 | resolveInitialServerAuthGateState, | |
| 7 | } from "../environments/primary"; | |
| 8 | ||
| 9 | export const Route = createFileRoute("/pair")({ | |
| 10 | beforeLoad: async () => { | |
| 11 | const [, authGateState] = await Promise.all([ | |
| 12 | ensurePrimaryEnvironmentReady(), | |
| 13 | resolveInitialServerAuthGateState(), | |
| 14 | ]); | |
| 15 | if (authGateState.status === "authenticated") { | |
| 16 | throw redirect({ to: "/", replace: true }); | |
| 17 | } | |
| 18 | return { | |
| 19 | authGateState, | |
| 20 | }; | |
| 21 | }, | |
| 22 | component: PairRouteView, | |
| 23 | pendingComponent: PairRoutePendingView, | |
| 24 | }); | |
| 25 | ||
| 26 | function PairRouteView() { | |
| 27 | const { authGateState } = Route.useRouteContext(); | |
| 28 | const navigate = useNavigate(); | |
| 29 | ||
| 30 | if (!authGateState) { | |
| 31 | return null; | |
| 32 | } | |
| 33 | ||
| 34 | return ( | |
| 35 | <PairingRouteSurface | |
| 36 | auth={authGateState.auth} | |
| 37 | onAuthenticated={() => { | |
| 38 | void navigate({ to: "/", replace: true }); | |
| 39 | }} | |
| 40 | {...(authGateState.errorMessage ? { initialErrorMessage: authGateState.errorMessage } : {})} | |
| 41 | /> | |
| 42 | ); | |
| 43 | } | |
| 44 | ||
| 45 | function PairRoutePendingView() { | |
| 46 | return <PairingPendingSurface />; | |
| 47 | } |
apps/web/src/routes/settings.connections.tsx created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { ConnectionsSettings } from "../components/settings/ConnectionsSettings"; | |
| 4 | ||
| 5 | export const Route = createFileRoute("/settings/connections")({ | |
| 6 | component: ConnectionsSettings, | |
| 7 | }); |
apps/web/src/routes/settings.tsx+13-1| ... | ... | @@ -2,6 +2,10 @@ import { RotateCcwIcon } from "lucide-react"; |
| 2 | 2 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; |
| 3 | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | |
| 5 | import { | |
| 6 | ensurePrimaryEnvironmentReady, | |
| 7 | resolveInitialServerAuthGateState, | |
| 8 | } from "../environments/primary"; | |
| 5 | 9 | import { useSettingsRestore } from "../components/settings/SettingsPanels"; |
| 6 | 10 | import { Button } from "../components/ui/button"; |
| 7 | 11 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; |
| ... | ... | @@ -83,7 +87,15 @@ function SettingsRouteLayout() { |
| 83 | 87 | } |
| 84 | 88 | |
| 85 | 89 | export const Route = createFileRoute("/settings")({ |
| 86 | beforeLoad: ({ location }) => { | |
| 90 | beforeLoad: async ({ location }) => { | |
| 91 | const [, authGateState] = await Promise.all([ | |
| 92 | ensurePrimaryEnvironmentReady(), | |
| 93 | resolveInitialServerAuthGateState(), | |
| 94 | ]); | |
| 95 | if (authGateState.status !== "authenticated") { | |
| 96 | throw redirect({ to: "/pair", replace: true }); | |
| 97 | } | |
| 98 | ||
| 87 | 99 | if (location.pathname === "/settings") { |
| 88 | 100 | throw redirect({ to: "/settings/general", replace: true }); |
| 89 | 101 | } |
apps/web/src/rpc/client.test.ts deleted-279| ... | ... | @@ -1,279 +0,0 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; | |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"; | |
| 4 | ||
| 5 | import { | |
| 6 | configureClientTracing, | |
| 7 | __resetClientTracingForTests, | |
| 8 | } from "../observability/clientTracing"; | |
| 9 | import { __resetWsRpcAtomClientForTests, runRpc, WsRpcAtomClient } from "./client"; | |
| 10 | ||
| 11 | type WsEventType = "open" | "message" | "close" | "error"; | |
| 12 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; | |
| 13 | type WsListener = (event?: WsEvent) => void; | |
| 14 | ||
| 15 | const sockets: MockWebSocket[] = []; | |
| 16 | ||
| 17 | class MockWebSocket { | |
| 18 | static readonly CONNECTING = 0; | |
| 19 | static readonly OPEN = 1; | |
| 20 | static readonly CLOSING = 2; | |
| 21 | static readonly CLOSED = 3; | |
| 22 | ||
| 23 | readyState = MockWebSocket.CONNECTING; | |
| 24 | readonly sent: string[] = []; | |
| 25 | readonly url: string; | |
| 26 | private readonly listeners = new Map<WsEventType, Set<WsListener>>(); | |
| 27 | ||
| 28 | constructor(url: string) { | |
| 29 | this.url = url; | |
| 30 | sockets.push(this); | |
| 31 | } | |
| 32 | ||
| 33 | addEventListener(type: WsEventType, listener: WsListener) { | |
| 34 | const listeners = this.listeners.get(type) ?? new Set<WsListener>(); | |
| 35 | listeners.add(listener); | |
| 36 | this.listeners.set(type, listeners); | |
| 37 | } | |
| 38 | ||
| 39 | removeEventListener(type: WsEventType, listener: WsListener) { | |
| 40 | this.listeners.get(type)?.delete(listener); | |
| 41 | } | |
| 42 | ||
| 43 | send(data: string) { | |
| 44 | this.sent.push(data); | |
| 45 | } | |
| 46 | ||
| 47 | close(code = 1000, reason = "") { | |
| 48 | this.readyState = MockWebSocket.CLOSED; | |
| 49 | this.emit("close", { code, reason, type: "close" }); | |
| 50 | } | |
| 51 | ||
| 52 | open() { | |
| 53 | this.readyState = MockWebSocket.OPEN; | |
| 54 | this.emit("open", { type: "open" }); | |
| 55 | } | |
| 56 | ||
| 57 | serverMessage(data: unknown) { | |
| 58 | this.emit("message", { data, type: "message" }); | |
| 59 | } | |
| 60 | ||
| 61 | private emit(type: WsEventType, event?: WsEvent) { | |
| 62 | const listeners = this.listeners.get(type); | |
| 63 | if (!listeners) { | |
| 64 | return; | |
| 65 | } | |
| 66 | for (const listener of listeners) { | |
| 67 | listener(event); | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | const originalWebSocket = globalThis.WebSocket; | |
| 73 | const originalFetch = globalThis.fetch; | |
| 74 | ||
| 75 | function getSocket(): MockWebSocket { | |
| 76 | const socket = sockets.at(-1); | |
| 77 | if (!socket) { | |
| 78 | throw new Error("Expected a websocket instance"); | |
| 79 | } | |
| 80 | return socket; | |
| 81 | } | |
| 82 | ||
| 83 | async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { | |
| 84 | const startedAt = Date.now(); | |
| 85 | for (;;) { | |
| 86 | try { | |
| 87 | assertion(); | |
| 88 | return; | |
| 89 | } catch (error) { | |
| 90 | if (Date.now() - startedAt >= timeoutMs) { | |
| 91 | throw error; | |
| 92 | } | |
| 93 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 94 | } | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | beforeEach(() => { | |
| 99 | sockets.length = 0; | |
| 100 | ||
| 101 | Object.defineProperty(globalThis, "window", { | |
| 102 | configurable: true, | |
| 103 | value: { | |
| 104 | location: { | |
| 105 | hostname: "localhost", | |
| 106 | origin: "http://localhost:3020", | |
| 107 | port: "3020", | |
| 108 | protocol: "ws:", | |
| 109 | }, | |
| 110 | desktopBridge: undefined, | |
| 111 | }, | |
| 112 | }); | |
| 113 | ||
| 114 | globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; | |
| 115 | }); | |
| 116 | ||
| 117 | afterEach(() => { | |
| 118 | __resetWsRpcAtomClientForTests(); | |
| 119 | globalThis.WebSocket = originalWebSocket; | |
| 120 | globalThis.fetch = originalFetch; | |
| 121 | vi.restoreAllMocks(); | |
| 122 | }); | |
| 123 | ||
| 124 | describe("WsRpcAtomClient", () => { | |
| 125 | it("runs unary requests through the AtomRpc service", async () => { | |
| 126 | const expectedSettings = { | |
| 127 | ...DEFAULT_SERVER_SETTINGS, | |
| 128 | enableAssistantStreaming: true, | |
| 129 | defaultThreadEnvMode: "worktree" as const, | |
| 130 | textGenerationModelSelection: { | |
| 131 | provider: "codex" as const, | |
| 132 | model: "gpt-5.4", | |
| 133 | }, | |
| 134 | providers: { | |
| 135 | codex: { | |
| 136 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 137 | homePath: "/tmp/codex-home", | |
| 138 | }, | |
| 139 | claudeAgent: { | |
| 140 | ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, | |
| 141 | enabled: false, | |
| 142 | }, | |
| 143 | }, | |
| 144 | }; | |
| 145 | const requestPromise = runRpc((client) => client(WS_METHODS.serverGetSettings, {})); | |
| 146 | ||
| 147 | await waitFor(() => { | |
| 148 | expect(sockets).toHaveLength(1); | |
| 149 | }); | |
| 150 | ||
| 151 | const socket = getSocket(); | |
| 152 | socket.open(); | |
| 153 | ||
| 154 | await waitFor(() => { | |
| 155 | expect(socket.sent).toHaveLength(1); | |
| 156 | }); | |
| 157 | ||
| 158 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | |
| 159 | expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings); | |
| 160 | ||
| 161 | socket.serverMessage( | |
| 162 | JSON.stringify({ | |
| 163 | _tag: "Exit", | |
| 164 | requestId: requestMessage.id, | |
| 165 | exit: { | |
| 166 | _tag: "Success", | |
| 167 | value: expectedSettings, | |
| 168 | }, | |
| 169 | }), | |
| 170 | ); | |
| 171 | ||
| 172 | await expect(requestPromise).resolves.toEqual(expectedSettings); | |
| 173 | }); | |
| 174 | ||
| 175 | it("exposes atom-backed query state for unary RPC methods", async () => { | |
| 176 | const expectedSettings = { | |
| 177 | ...DEFAULT_SERVER_SETTINGS, | |
| 178 | enableAssistantStreaming: true, | |
| 179 | defaultThreadEnvMode: "worktree" as const, | |
| 180 | textGenerationModelSelection: { | |
| 181 | provider: "codex" as const, | |
| 182 | model: "gpt-5.4", | |
| 183 | }, | |
| 184 | providers: { | |
| 185 | codex: { | |
| 186 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 187 | homePath: "/tmp/codex-home", | |
| 188 | }, | |
| 189 | claudeAgent: { | |
| 190 | ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, | |
| 191 | enabled: false, | |
| 192 | }, | |
| 193 | }, | |
| 194 | }; | |
| 195 | const registry = AtomRegistry.make(); | |
| 196 | const query = WsRpcAtomClient.query(WS_METHODS.serverGetSettings, {}); | |
| 197 | const release = registry.mount(query); | |
| 198 | ||
| 199 | await waitFor(() => { | |
| 200 | expect(sockets).toHaveLength(1); | |
| 201 | }); | |
| 202 | ||
| 203 | const socket = getSocket(); | |
| 204 | socket.open(); | |
| 205 | ||
| 206 | await waitFor(() => { | |
| 207 | expect(socket.sent).toHaveLength(1); | |
| 208 | }); | |
| 209 | ||
| 210 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | |
| 211 | expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings); | |
| 212 | expect(registry.get(query)._tag).toBe("Initial"); | |
| 213 | ||
| 214 | socket.serverMessage( | |
| 215 | JSON.stringify({ | |
| 216 | _tag: "Exit", | |
| 217 | requestId: requestMessage.id, | |
| 218 | exit: { | |
| 219 | _tag: "Success", | |
| 220 | value: expectedSettings, | |
| 221 | }, | |
| 222 | }), | |
| 223 | ); | |
| 224 | ||
| 225 | await waitFor(() => { | |
| 226 | const result = registry.get(query); | |
| 227 | expect(AsyncResult.isSuccess(result)).toBe(true); | |
| 228 | if (!AsyncResult.isSuccess(result)) { | |
| 229 | return; | |
| 230 | } | |
| 231 | expect(result.value).toEqual(expectedSettings); | |
| 232 | }); | |
| 233 | ||
| 234 | release(); | |
| 235 | registry.dispose(); | |
| 236 | }); | |
| 237 | ||
| 238 | it("attaches distributed trace ids when client OTLP tracing is enabled", async () => { | |
| 239 | await configureClientTracing({ | |
| 240 | exportIntervalMs: 10, | |
| 241 | }); | |
| 242 | ||
| 243 | const requestPromise = runRpc((client) => client(WS_METHODS.serverGetSettings, {})); | |
| 244 | ||
| 245 | await waitFor(() => { | |
| 246 | expect(sockets).toHaveLength(1); | |
| 247 | }); | |
| 248 | ||
| 249 | const socket = getSocket(); | |
| 250 | socket.open(); | |
| 251 | ||
| 252 | await waitFor(() => { | |
| 253 | expect(socket.sent).toHaveLength(1); | |
| 254 | }); | |
| 255 | ||
| 256 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | |
| 257 | id: string; | |
| 258 | spanId?: string; | |
| 259 | tag: string; | |
| 260 | traceId?: string; | |
| 261 | }; | |
| 262 | expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings); | |
| 263 | expect(requestMessage.traceId).toMatch(/^[0-9a-f]{32}$/); | |
| 264 | expect(requestMessage.spanId).toMatch(/^[0-9a-f]{16}$/); | |
| 265 | ||
| 266 | socket.serverMessage( | |
| 267 | JSON.stringify({ | |
| 268 | _tag: "Exit", | |
| 269 | requestId: requestMessage.id, | |
| 270 | exit: { | |
| 271 | _tag: "Success", | |
| 272 | value: DEFAULT_SERVER_SETTINGS, | |
| 273 | }, | |
| 274 | }), | |
| 275 | ); | |
| 276 | ||
| 277 | await expect(requestPromise).resolves.toEqual(DEFAULT_SERVER_SETTINGS); | |
| 278 | }); | |
| 279 | }); |
apps/web/src/rpc/client.ts deleted-42| ... | ... | @@ -1,42 +0,0 @@ |
| 1 | import { WsRpcGroup } from "@t3tools/contracts"; | |
| 2 | import { Effect, Layer, ManagedRuntime } from "effect"; | |
| 3 | import { AtomRpc } from "effect/unstable/reactivity"; | |
| 4 | ||
| 5 | import { | |
| 6 | __resetClientTracingForTests, | |
| 7 | ClientTracingLive, | |
| 8 | configureClientTracing, | |
| 9 | } from "../observability/clientTracing"; | |
| 10 | import { createWsRpcProtocolLayer } from "./protocol"; | |
| 11 | ||
| 12 | export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", { | |
| 13 | group: WsRpcGroup, | |
| 14 | protocol: Layer.suspend(() => createWsRpcProtocolLayer()), | |
| 15 | }) {} | |
| 16 | ||
| 17 | let sharedRuntime: ManagedRuntime.ManagedRuntime<WsRpcAtomClient, never> | null = null; | |
| 18 | ||
| 19 | function getRuntime() { | |
| 20 | if (sharedRuntime !== null) { | |
| 21 | return sharedRuntime; | |
| 22 | } | |
| 23 | ||
| 24 | sharedRuntime = ManagedRuntime.make(Layer.mergeAll(WsRpcAtomClient.layer, ClientTracingLive)); | |
| 25 | return sharedRuntime; | |
| 26 | } | |
| 27 | ||
| 28 | export function runRpc<TSuccess, TError = never>( | |
| 29 | execute: (client: typeof WsRpcAtomClient.Service) => Effect.Effect<TSuccess, TError, never>, | |
| 30 | ): Promise<TSuccess> { | |
| 31 | return configureClientTracing().then(() => { | |
| 32 | const runtime = getRuntime(); | |
| 33 | return runtime.runPromise(WsRpcAtomClient.use(execute)); | |
| 34 | }); | |
| 35 | } | |
| 36 | ||
| 37 | export async function __resetWsRpcAtomClientForTests() { | |
| 38 | const runtime = sharedRuntime; | |
| 39 | sharedRuntime = null; | |
| 40 | await runtime?.dispose(); | |
| 41 | await __resetClientTracingForTests(); | |
| 42 | } |
apps/web/src/rpc/protocol.ts+65-14| ... | ... | @@ -3,7 +3,6 @@ import { Duration, Effect, Layer, Schedule } from "effect"; |
| 3 | 3 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; |
| 4 | 4 | import * as Socket from "effect/unstable/socket/Socket"; |
| 5 | 5 | |
| 6 | import { resolveServerUrl } from "../lib/utils"; | |
| 7 | 6 | import { |
| 8 | 7 | acknowledgeRpcRequest, |
| 9 | 8 | clearAllTrackedRpcRequests, |
| ... | ... | @@ -18,44 +17,96 @@ import { |
| 18 | 17 | WS_RECONNECT_MAX_RETRIES, |
| 19 | 18 | } from "./wsConnectionState"; |
| 20 | 19 | |
| 21 | export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); | |
| 20 | export interface WsProtocolLifecycleHandlers { | |
| 21 | readonly onAttempt?: (socketUrl: string) => void; | |
| 22 | readonly onOpen?: () => void; | |
| 23 | readonly onError?: (message: string) => void; | |
| 24 | readonly onClose?: (details: { readonly code: number; readonly reason: string }) => void; | |
| 25 | } | |
| 22 | 26 | |
| 27 | export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); | |
| 23 | 28 | type RpcClientFactory = typeof makeWsRpcProtocolClient; |
| 24 | 29 | export type WsRpcProtocolClient = |
| 25 | 30 | RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never; |
| 31 | export type WsRpcProtocolSocketUrlProvider = string | (() => Promise<string>); | |
| 32 | ||
| 33 | function formatSocketErrorMessage(error: unknown): string { | |
| 34 | if (error instanceof Error && error.message.trim().length > 0) { | |
| 35 | return error.message; | |
| 36 | } | |
| 37 | return String(error); | |
| 38 | } | |
| 39 | ||
| 40 | function resolveWsRpcSocketUrl(rawUrl: string): string { | |
| 41 | const resolved = new URL(rawUrl); | |
| 42 | if (resolved.protocol !== "ws:" && resolved.protocol !== "wss:") { | |
| 43 | throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); | |
| 44 | } | |
| 45 | ||
| 46 | resolved.pathname = "/ws"; | |
| 47 | return resolved.toString(); | |
| 48 | } | |
| 49 | ||
| 50 | function defaultLifecycleHandlers(): Required<WsProtocolLifecycleHandlers> { | |
| 51 | return { | |
| 52 | onAttempt: recordWsConnectionAttempt, | |
| 53 | onOpen: recordWsConnectionOpened, | |
| 54 | onError: (message) => { | |
| 55 | clearAllTrackedRpcRequests(); | |
| 56 | recordWsConnectionErrored(message); | |
| 57 | }, | |
| 58 | onClose: (details) => { | |
| 59 | clearAllTrackedRpcRequests(); | |
| 60 | recordWsConnectionClosed(details); | |
| 61 | }, | |
| 62 | }; | |
| 63 | } | |
| 64 | ||
| 65 | export function createWsRpcProtocolLayer( | |
| 66 | url: WsRpcProtocolSocketUrlProvider, | |
| 67 | handlers?: WsProtocolLifecycleHandlers, | |
| 68 | ) { | |
| 69 | const lifecycle = { | |
| 70 | ...defaultLifecycleHandlers(), | |
| 71 | ...handlers, | |
| 72 | }; | |
| 73 | const resolvedUrl = | |
| 74 | typeof url === "function" | |
| 75 | ? Effect.promise(() => url()).pipe( | |
| 76 | Effect.map((rawUrl) => resolveWsRpcSocketUrl(rawUrl)), | |
| 77 | Effect.tapError((error) => | |
| 78 | Effect.sync(() => { | |
| 79 | lifecycle.onError(formatSocketErrorMessage(error)); | |
| 80 | }), | |
| 81 | ), | |
| 82 | Effect.orDie, | |
| 83 | ) | |
| 84 | : resolveWsRpcSocketUrl(url); | |
| 26 | 85 | |
| 27 | export function createWsRpcProtocolLayer(url?: string) { | |
| 28 | const resolvedUrl = resolveServerUrl({ | |
| 29 | url, | |
| 30 | protocol: window.location.protocol === "https:" ? "wss" : "ws", | |
| 31 | pathname: "/ws", | |
| 32 | }); | |
| 33 | 86 | const trackingWebSocketConstructorLayer = Layer.succeed( |
| 34 | 87 | Socket.WebSocketConstructor, |
| 35 | 88 | (socketUrl, protocols) => { |
| 36 | recordWsConnectionAttempt(socketUrl); | |
| 89 | lifecycle.onAttempt(socketUrl); | |
| 37 | 90 | const socket = new globalThis.WebSocket(socketUrl, protocols); |
| 38 | 91 | |
| 39 | 92 | socket.addEventListener( |
| 40 | 93 | "open", |
| 41 | 94 | () => { |
| 42 | recordWsConnectionOpened(); | |
| 95 | lifecycle.onOpen(); | |
| 43 | 96 | }, |
| 44 | 97 | { once: true }, |
| 45 | 98 | ); |
| 46 | 99 | socket.addEventListener( |
| 47 | 100 | "error", |
| 48 | 101 | () => { |
| 49 | clearAllTrackedRpcRequests(); | |
| 50 | recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); | |
| 102 | lifecycle.onError("Unable to connect to the T3 server WebSocket."); | |
| 51 | 103 | }, |
| 52 | 104 | { once: true }, |
| 53 | 105 | ); |
| 54 | 106 | socket.addEventListener( |
| 55 | 107 | "close", |
| 56 | 108 | (event) => { |
| 57 | clearAllTrackedRpcRequests(); | |
| 58 | recordWsConnectionClosed({ | |
| 109 | lifecycle.onClose({ | |
| 59 | 110 | code: event.code, |
| 60 | 111 | reason: event.reason, |
| 61 | 112 | }); |
apps/web/src/rpc/serverState.test.ts+6| ... | ... | @@ -66,6 +66,12 @@ const baseEnvironment = { |
| 66 | 66 | |
| 67 | 67 | const baseServerConfig: ServerConfig = { |
| 68 | 68 | environment: baseEnvironment, |
| 69 | auth: { | |
| 70 | policy: "loopback-browser", | |
| 71 | bootstrapMethods: ["one-time-token"], | |
| 72 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 73 | sessionCookieName: "t3_session", | |
| 74 | }, | |
| 69 | 75 | cwd: "/tmp/workspace", |
| 70 | 76 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", |
| 71 | 77 | keybindings: [], |
apps/web/src/rpc/serverState.ts+1-1| ... | ... | @@ -13,7 +13,7 @@ import { |
| 13 | 13 | import { Atom } from "effect/unstable/reactivity"; |
| 14 | 14 | import { useCallback, useRef } from "react"; |
| 15 | 15 | |
| 16 | import type { WsRpcClient } from "../wsRpcClient"; | |
| 16 | import type { WsRpcClient } from "./wsRpcClient"; | |
| 17 | 17 | import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; |
| 18 | 18 | |
| 19 | 19 | export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; |
apps/web/src/rpc/wsRpcClient.test.ts created+104| ... | ... | @@ -0,0 +1,104 @@ |
| 1 | import type { | |
| 2 | GitStatusLocalResult, | |
| 3 | GitStatusRemoteResult, | |
| 4 | GitStatusStreamEvent, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { describe, expect, it, vi } from "vitest"; | |
| 7 | ||
| 8 | vi.mock("./wsTransport", () => ({ | |
| 9 | WsTransport: class WsTransport { | |
| 10 | dispose = vi.fn(async () => undefined); | |
| 11 | reconnect = vi.fn(async () => undefined); | |
| 12 | request = vi.fn(); | |
| 13 | requestStream = vi.fn(); | |
| 14 | subscribe = vi.fn(() => () => undefined); | |
| 15 | }, | |
| 16 | })); | |
| 17 | ||
| 18 | import { createWsRpcClient } from "./wsRpcClient"; | |
| 19 | import { type WsTransport } from "./wsTransport"; | |
| 20 | ||
| 21 | const baseLocalStatus: GitStatusLocalResult = { | |
| 22 | isRepo: true, | |
| 23 | hasOriginRemote: true, | |
| 24 | isDefaultBranch: false, | |
| 25 | branch: "feature/demo", | |
| 26 | hasWorkingTreeChanges: false, | |
| 27 | workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 28 | }; | |
| 29 | ||
| 30 | const baseRemoteStatus: GitStatusRemoteResult = { | |
| 31 | hasUpstream: true, | |
| 32 | aheadCount: 0, | |
| 33 | behindCount: 0, | |
| 34 | pr: null, | |
| 35 | }; | |
| 36 | ||
| 37 | describe("wsRpcClient", () => { | |
| 38 | it("reduces git status stream events into flat status snapshots", () => { | |
| 39 | const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => { | |
| 40 | for (const event of [ | |
| 41 | { | |
| 42 | _tag: "snapshot", | |
| 43 | local: baseLocalStatus, | |
| 44 | remote: null, | |
| 45 | }, | |
| 46 | { | |
| 47 | _tag: "remoteUpdated", | |
| 48 | remote: baseRemoteStatus, | |
| 49 | }, | |
| 50 | { | |
| 51 | _tag: "localUpdated", | |
| 52 | local: { | |
| 53 | ...baseLocalStatus, | |
| 54 | hasWorkingTreeChanges: true, | |
| 55 | }, | |
| 56 | }, | |
| 57 | ] satisfies GitStatusStreamEvent[]) { | |
| 58 | listener(event as TValue); | |
| 59 | } | |
| 60 | return () => undefined; | |
| 61 | }); | |
| 62 | ||
| 63 | const transport = { | |
| 64 | dispose: vi.fn(async () => undefined), | |
| 65 | reconnect: vi.fn(async () => undefined), | |
| 66 | request: vi.fn(), | |
| 67 | requestStream: vi.fn(), | |
| 68 | subscribe, | |
| 69 | } satisfies Pick< | |
| 70 | WsTransport, | |
| 71 | "dispose" | "reconnect" | "request" | "requestStream" | "subscribe" | |
| 72 | >; | |
| 73 | ||
| 74 | const client = createWsRpcClient(transport as unknown as WsTransport); | |
| 75 | const listener = vi.fn(); | |
| 76 | ||
| 77 | client.git.onStatus({ cwd: "/repo" }, listener); | |
| 78 | ||
| 79 | expect(listener.mock.calls).toEqual([ | |
| 80 | [ | |
| 81 | { | |
| 82 | ...baseLocalStatus, | |
| 83 | hasUpstream: false, | |
| 84 | aheadCount: 0, | |
| 85 | behindCount: 0, | |
| 86 | pr: null, | |
| 87 | }, | |
| 88 | ], | |
| 89 | [ | |
| 90 | { | |
| 91 | ...baseLocalStatus, | |
| 92 | ...baseRemoteStatus, | |
| 93 | }, | |
| 94 | ], | |
| 95 | [ | |
| 96 | { | |
| 97 | ...baseLocalStatus, | |
| 98 | ...baseRemoteStatus, | |
| 99 | hasWorkingTreeChanges: true, | |
| 100 | }, | |
| 101 | ], | |
| 102 | ]); | |
| 103 | }); | |
| 104 | }); |
apps/web/src/rpc/wsRpcClient.ts created+242| ... | ... | @@ -0,0 +1,242 @@ |
| 1 | import { | |
| 2 | type GitActionProgressEvent, | |
| 3 | type GitRunStackedActionInput, | |
| 4 | type GitRunStackedActionResult, | |
| 5 | type GitStatusResult, | |
| 6 | type GitStatusStreamEvent, | |
| 7 | type LocalApi, | |
| 8 | ORCHESTRATION_WS_METHODS, | |
| 9 | type ServerSettingsPatch, | |
| 10 | WS_METHODS, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; | |
| 13 | import { Effect, Stream } from "effect"; | |
| 14 | ||
| 15 | import { type WsRpcProtocolClient } from "./protocol"; | |
| 16 | import { resetWsReconnectBackoff } from "./wsConnectionState"; | |
| 17 | import { WsTransport } from "./wsTransport"; | |
| 18 | ||
| 19 | type RpcTag = keyof WsRpcProtocolClient & string; | |
| 20 | type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag]; | |
| 21 | type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0]; | |
| 22 | ||
| 23 | interface StreamSubscriptionOptions { | |
| 24 | readonly onResubscribe?: () => void; | |
| 25 | } | |
| 26 | ||
| 27 | type RpcUnaryMethod<TTag extends RpcTag> = | |
| 28 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | |
| 29 | ? (input: RpcInput<TTag>) => Promise<TSuccess> | |
| 30 | : never; | |
| 31 | ||
| 32 | type RpcUnaryNoArgMethod<TTag extends RpcTag> = | |
| 33 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | |
| 34 | ? () => Promise<TSuccess> | |
| 35 | : never; | |
| 36 | ||
| 37 | type RpcStreamMethod<TTag extends RpcTag> = | |
| 38 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any> | |
| 39 | ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void | |
| 40 | : never; | |
| 41 | ||
| 42 | interface GitRunStackedActionOptions { | |
| 43 | readonly onProgress?: (event: GitActionProgressEvent) => void; | |
| 44 | } | |
| 45 | ||
| 46 | export interface WsRpcClient { | |
| 47 | readonly dispose: () => Promise<void>; | |
| 48 | readonly reconnect: () => Promise<void>; | |
| 49 | readonly terminal: { | |
| 50 | readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>; | |
| 51 | readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>; | |
| 52 | readonly resize: RpcUnaryMethod<typeof WS_METHODS.terminalResize>; | |
| 53 | readonly clear: RpcUnaryMethod<typeof WS_METHODS.terminalClear>; | |
| 54 | readonly restart: RpcUnaryMethod<typeof WS_METHODS.terminalRestart>; | |
| 55 | readonly close: RpcUnaryMethod<typeof WS_METHODS.terminalClose>; | |
| 56 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>; | |
| 57 | }; | |
| 58 | readonly projects: { | |
| 59 | readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>; | |
| 60 | readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>; | |
| 61 | }; | |
| 62 | readonly shell: { | |
| 63 | readonly openInEditor: (input: { | |
| 64 | readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0]; | |
| 65 | readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1]; | |
| 66 | }) => ReturnType<LocalApi["shell"]["openInEditor"]>; | |
| 67 | }; | |
| 68 | readonly git: { | |
| 69 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; | |
| 70 | readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.gitRefreshStatus>; | |
| 71 | readonly onStatus: ( | |
| 72 | input: RpcInput<typeof WS_METHODS.subscribeGitStatus>, | |
| 73 | listener: (status: GitStatusResult) => void, | |
| 74 | options?: StreamSubscriptionOptions, | |
| 75 | ) => () => void; | |
| 76 | readonly runStackedAction: ( | |
| 77 | input: GitRunStackedActionInput, | |
| 78 | options?: GitRunStackedActionOptions, | |
| 79 | ) => Promise<GitRunStackedActionResult>; | |
| 80 | readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>; | |
| 81 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>; | |
| 82 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>; | |
| 83 | readonly createBranch: RpcUnaryMethod<typeof WS_METHODS.gitCreateBranch>; | |
| 84 | readonly checkout: RpcUnaryMethod<typeof WS_METHODS.gitCheckout>; | |
| 85 | readonly init: RpcUnaryMethod<typeof WS_METHODS.gitInit>; | |
| 86 | readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>; | |
| 87 | readonly preparePullRequestThread: RpcUnaryMethod< | |
| 88 | typeof WS_METHODS.gitPreparePullRequestThread | |
| 89 | >; | |
| 90 | }; | |
| 91 | readonly server: { | |
| 92 | readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>; | |
| 93 | readonly refreshProviders: RpcUnaryNoArgMethod<typeof WS_METHODS.serverRefreshProviders>; | |
| 94 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; | |
| 95 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; | |
| 96 | readonly updateSettings: ( | |
| 97 | patch: ServerSettingsPatch, | |
| 98 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>; | |
| 99 | readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>; | |
| 100 | readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>; | |
| 101 | readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>; | |
| 102 | }; | |
| 103 | readonly orchestration: { | |
| 104 | readonly getSnapshot: RpcUnaryNoArgMethod<typeof ORCHESTRATION_WS_METHODS.getSnapshot>; | |
| 105 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; | |
| 106 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; | |
| 107 | readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>; | |
| 108 | readonly replayEvents: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.replayEvents>; | |
| 109 | readonly onDomainEvent: RpcStreamMethod<typeof WS_METHODS.subscribeOrchestrationDomainEvents>; | |
| 110 | }; | |
| 111 | } | |
| 112 | ||
| 113 | export function createWsRpcClient(transport: WsTransport): WsRpcClient { | |
| 114 | return { | |
| 115 | dispose: () => transport.dispose(), | |
| 116 | reconnect: async () => { | |
| 117 | resetWsReconnectBackoff(); | |
| 118 | await transport.reconnect(); | |
| 119 | }, | |
| 120 | terminal: { | |
| 121 | open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), | |
| 122 | write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), | |
| 123 | resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), | |
| 124 | clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), | |
| 125 | restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), | |
| 126 | close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), | |
| 127 | onEvent: (listener, options) => | |
| 128 | transport.subscribe( | |
| 129 | (client) => client[WS_METHODS.subscribeTerminalEvents]({}), | |
| 130 | listener, | |
| 131 | options, | |
| 132 | ), | |
| 133 | }, | |
| 134 | projects: { | |
| 135 | searchEntries: (input) => | |
| 136 | transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), | |
| 137 | writeFile: (input) => | |
| 138 | transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), | |
| 139 | }, | |
| 140 | shell: { | |
| 141 | openInEditor: (input) => | |
| 142 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), | |
| 143 | }, | |
| 144 | git: { | |
| 145 | pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), | |
| 146 | refreshStatus: (input) => | |
| 147 | transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), | |
| 148 | onStatus: (input, listener, options) => { | |
| 149 | let current: GitStatusResult | null = null; | |
| 150 | return transport.subscribe( | |
| 151 | (client) => client[WS_METHODS.subscribeGitStatus](input), | |
| 152 | (event: GitStatusStreamEvent) => { | |
| 153 | current = applyGitStatusStreamEvent(current, event); | |
| 154 | listener(current); | |
| 155 | }, | |
| 156 | options, | |
| 157 | ); | |
| 158 | }, | |
| 159 | runStackedAction: async (input, options) => { | |
| 160 | let result: GitRunStackedActionResult | null = null; | |
| 161 | ||
| 162 | await transport.requestStream( | |
| 163 | (client) => client[WS_METHODS.gitRunStackedAction](input), | |
| 164 | (event) => { | |
| 165 | options?.onProgress?.(event); | |
| 166 | if (event.kind === "action_finished") { | |
| 167 | result = event.result; | |
| 168 | } | |
| 169 | }, | |
| 170 | ); | |
| 171 | ||
| 172 | if (result) { | |
| 173 | return result; | |
| 174 | } | |
| 175 | ||
| 176 | throw new Error("Git action stream completed without a final result."); | |
| 177 | }, | |
| 178 | listBranches: (input) => | |
| 179 | transport.request((client) => client[WS_METHODS.gitListBranches](input)), | |
| 180 | createWorktree: (input) => | |
| 181 | transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)), | |
| 182 | removeWorktree: (input) => | |
| 183 | transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)), | |
| 184 | createBranch: (input) => | |
| 185 | transport.request((client) => client[WS_METHODS.gitCreateBranch](input)), | |
| 186 | checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)), | |
| 187 | init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)), | |
| 188 | resolvePullRequest: (input) => | |
| 189 | transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), | |
| 190 | preparePullRequestThread: (input) => | |
| 191 | transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), | |
| 192 | }, | |
| 193 | server: { | |
| 194 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), | |
| 195 | refreshProviders: () => | |
| 196 | transport.request((client) => client[WS_METHODS.serverRefreshProviders]({})), | |
| 197 | upsertKeybinding: (input) => | |
| 198 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), | |
| 199 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), | |
| 200 | updateSettings: (patch) => | |
| 201 | transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), | |
| 202 | subscribeConfig: (listener, options) => | |
| 203 | transport.subscribe( | |
| 204 | (client) => client[WS_METHODS.subscribeServerConfig]({}), | |
| 205 | listener, | |
| 206 | options, | |
| 207 | ), | |
| 208 | subscribeLifecycle: (listener, options) => | |
| 209 | transport.subscribe( | |
| 210 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | |
| 211 | listener, | |
| 212 | options, | |
| 213 | ), | |
| 214 | subscribeAuthAccess: (listener, options) => | |
| 215 | transport.subscribe( | |
| 216 | (client) => client[WS_METHODS.subscribeAuthAccess]({}), | |
| 217 | listener, | |
| 218 | options, | |
| 219 | ), | |
| 220 | }, | |
| 221 | orchestration: { | |
| 222 | getSnapshot: () => | |
| 223 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), | |
| 224 | dispatchCommand: (input) => | |
| 225 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), | |
| 226 | getTurnDiff: (input) => | |
| 227 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), | |
| 228 | getFullThreadDiff: (input) => | |
| 229 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), | |
| 230 | replayEvents: (input) => | |
| 231 | transport | |
| 232 | .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input)) | |
| 233 | .then((events) => [...events]), | |
| 234 | onDomainEvent: (listener, options) => | |
| 235 | transport.subscribe( | |
| 236 | (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), | |
| 237 | listener, | |
| 238 | options, | |
| 239 | ), | |
| 240 | }, | |
| 241 | }; | |
| 242 | } |
apps/web/src/rpc/wsTransport.test.ts created+863| ... | ... | @@ -0,0 +1,863 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; | |
| 2 | import { Stream } from "effect"; | |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 4 | ||
| 5 | import { | |
| 6 | __resetClientTracingForTests, | |
| 7 | configureClientTracing, | |
| 8 | } from "../observability/clientTracing"; | |
| 9 | import { | |
| 10 | getSlowRpcAckRequests, | |
| 11 | resetRequestLatencyStateForTests, | |
| 12 | setSlowRpcAckThresholdMsForTests, | |
| 13 | } from "../rpc/requestLatencyState"; | |
| 14 | import { | |
| 15 | getWsConnectionStatus, | |
| 16 | getWsConnectionUiState, | |
| 17 | resetWsConnectionStateForTests, | |
| 18 | } from "../rpc/wsConnectionState"; | |
| 19 | import { WsTransport } from "./wsTransport"; | |
| 20 | ||
| 21 | type WsEventType = "open" | "message" | "close" | "error"; | |
| 22 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; | |
| 23 | type WsListener = (event?: WsEvent) => void; | |
| 24 | ||
| 25 | const sockets: MockWebSocket[] = []; | |
| 26 | ||
| 27 | class MockWebSocket { | |
| 28 | static readonly CONNECTING = 0; | |
| 29 | static readonly OPEN = 1; | |
| 30 | static readonly CLOSING = 2; | |
| 31 | static readonly CLOSED = 3; | |
| 32 | ||
| 33 | readyState = MockWebSocket.CONNECTING; | |
| 34 | readonly sent: string[] = []; | |
| 35 | readonly url: string; | |
| 36 | private readonly listeners = new Map<WsEventType, Set<WsListener>>(); | |
| 37 | ||
| 38 | constructor(url: string) { | |
| 39 | this.url = url; | |
| 40 | sockets.push(this); | |
| 41 | } | |
| 42 | ||
| 43 | addEventListener(type: WsEventType, listener: WsListener) { | |
| 44 | const listeners = this.listeners.get(type) ?? new Set<WsListener>(); | |
| 45 | listeners.add(listener); | |
| 46 | this.listeners.set(type, listeners); | |
| 47 | } | |
| 48 | ||
| 49 | removeEventListener(type: WsEventType, listener: WsListener) { | |
| 50 | this.listeners.get(type)?.delete(listener); | |
| 51 | } | |
| 52 | ||
| 53 | send(data: string) { | |
| 54 | this.sent.push(data); | |
| 55 | } | |
| 56 | ||
| 57 | close(code = 1000, reason = "") { | |
| 58 | this.readyState = MockWebSocket.CLOSED; | |
| 59 | this.emit("close", { code, reason, type: "close" }); | |
| 60 | } | |
| 61 | ||
| 62 | open() { | |
| 63 | this.readyState = MockWebSocket.OPEN; | |
| 64 | this.emit("open", { type: "open" }); | |
| 65 | } | |
| 66 | ||
| 67 | serverMessage(data: unknown) { | |
| 68 | this.emit("message", { data, type: "message" }); | |
| 69 | } | |
| 70 | ||
| 71 | error() { | |
| 72 | this.emit("error", { type: "error" }); | |
| 73 | } | |
| 74 | ||
| 75 | private emit(type: WsEventType, event?: WsEvent) { | |
| 76 | const listeners = this.listeners.get(type); | |
| 77 | if (!listeners) return; | |
| 78 | for (const listener of listeners) { | |
| 79 | listener(event); | |
| 80 | } | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | const originalWebSocket = globalThis.WebSocket; | |
| 85 | const originalFetch = globalThis.fetch; | |
| 86 | ||
| 87 | function getSocket(): MockWebSocket { | |
| 88 | const socket = sockets.at(-1); | |
| 89 | if (!socket) { | |
| 90 | throw new Error("Expected a websocket instance"); | |
| 91 | } | |
| 92 | return socket; | |
| 93 | } | |
| 94 | ||
| 95 | async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { | |
| 96 | const startedAt = Date.now(); | |
| 97 | for (;;) { | |
| 98 | try { | |
| 99 | assertion(); | |
| 100 | return; | |
| 101 | } catch (error) { | |
| 102 | if (Date.now() - startedAt >= timeoutMs) { | |
| 103 | throw error; | |
| 104 | } | |
| 105 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 106 | } | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | beforeEach(() => { | |
| 111 | vi.useRealTimers(); | |
| 112 | sockets.length = 0; | |
| 113 | resetRequestLatencyStateForTests(); | |
| 114 | resetWsConnectionStateForTests(); | |
| 115 | ||
| 116 | Object.defineProperty(globalThis, "window", { | |
| 117 | configurable: true, | |
| 118 | value: { | |
| 119 | location: { | |
| 120 | origin: "http://localhost:3020", | |
| 121 | hostname: "localhost", | |
| 122 | port: "3020", | |
| 123 | protocol: "http:", | |
| 124 | }, | |
| 125 | desktopBridge: undefined, | |
| 126 | }, | |
| 127 | }); | |
| 128 | Object.defineProperty(globalThis, "navigator", { | |
| 129 | configurable: true, | |
| 130 | value: { onLine: true }, | |
| 131 | }); | |
| 132 | ||
| 133 | globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; | |
| 134 | }); | |
| 135 | ||
| 136 | afterEach(async () => { | |
| 137 | globalThis.WebSocket = originalWebSocket; | |
| 138 | globalThis.fetch = originalFetch; | |
| 139 | resetRequestLatencyStateForTests(); | |
| 140 | resetWsConnectionStateForTests(); | |
| 141 | await __resetClientTracingForTests(); | |
| 142 | vi.restoreAllMocks(); | |
| 143 | }); | |
| 144 | ||
| 145 | describe("WsTransport", () => { | |
| 146 | it("normalizes root websocket urls to /ws and preserves query params", async () => { | |
| 147 | const transport = new WsTransport("ws://localhost:3020/?token=secret-token"); | |
| 148 | ||
| 149 | await waitFor(() => { | |
| 150 | expect(sockets).toHaveLength(1); | |
| 151 | }); | |
| 152 | ||
| 153 | expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); | |
| 154 | await transport.dispose(); | |
| 155 | }); | |
| 156 | ||
| 157 | it("uses an explicit secure websocket base url", async () => { | |
| 158 | const transport = new WsTransport("wss://app.example.com"); | |
| 159 | ||
| 160 | await waitFor(() => { | |
| 161 | expect(sockets).toHaveLength(1); | |
| 162 | }); | |
| 163 | ||
| 164 | expect(getSocket().url).toBe("wss://app.example.com/ws"); | |
| 165 | await transport.dispose(); | |
| 166 | }); | |
| 167 | ||
| 168 | it("uses an explicit insecure websocket base url for remote backends", async () => { | |
| 169 | const transport = new WsTransport("ws://192.168.1.44:3773"); | |
| 170 | ||
| 171 | await waitFor(() => { | |
| 172 | expect(sockets).toHaveLength(1); | |
| 173 | }); | |
| 174 | ||
| 175 | expect(getSocket().url).toBe("ws://192.168.1.44:3773/ws"); | |
| 176 | await transport.dispose(); | |
| 177 | }); | |
| 178 | ||
| 179 | it("supports async websocket url providers", async () => { | |
| 180 | const transport = new WsTransport(async () => "wss://remote.example.com/?wsToken=dynamic"); | |
| 181 | ||
| 182 | await waitFor(() => { | |
| 183 | expect(sockets).toHaveLength(1); | |
| 184 | }); | |
| 185 | ||
| 186 | expect(getSocket().url).toBe("wss://remote.example.com/ws?wsToken=dynamic"); | |
| 187 | await transport.dispose(); | |
| 188 | }); | |
| 189 | ||
| 190 | it("tracks initial connection failures for the app error state", async () => { | |
| 191 | const transport = new WsTransport("ws://localhost:3020"); | |
| 192 | ||
| 193 | await waitFor(() => { | |
| 194 | expect(sockets).toHaveLength(1); | |
| 195 | }); | |
| 196 | ||
| 197 | const socket = getSocket(); | |
| 198 | expect(getWsConnectionStatus()).toMatchObject({ | |
| 199 | attemptCount: 1, | |
| 200 | phase: "connecting", | |
| 201 | socketUrl: "ws://localhost:3020/ws", | |
| 202 | }); | |
| 203 | ||
| 204 | socket.error(); | |
| 205 | socket.close(1006, "server unavailable"); | |
| 206 | ||
| 207 | await waitFor(() => { | |
| 208 | expect(getWsConnectionStatus()).toMatchObject({ | |
| 209 | closeCode: 1006, | |
| 210 | closeReason: "server unavailable", | |
| 211 | hasConnected: false, | |
| 212 | lastError: "Unable to connect to the T3 server WebSocket.", | |
| 213 | phase: "disconnected", | |
| 214 | }); | |
| 215 | }); | |
| 216 | expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("error"); | |
| 217 | ||
| 218 | await transport.dispose(); | |
| 219 | }); | |
| 220 | ||
| 221 | it("surfaces reconnecting state after a live socket disconnects", async () => { | |
| 222 | const transport = new WsTransport("ws://localhost:3020"); | |
| 223 | ||
| 224 | await waitFor(() => { | |
| 225 | expect(sockets).toHaveLength(1); | |
| 226 | }); | |
| 227 | ||
| 228 | const socket = getSocket(); | |
| 229 | socket.open(); | |
| 230 | ||
| 231 | await waitFor(() => { | |
| 232 | expect(getWsConnectionStatus()).toMatchObject({ | |
| 233 | hasConnected: true, | |
| 234 | phase: "connected", | |
| 235 | }); | |
| 236 | }); | |
| 237 | ||
| 238 | socket.close(1013, "try again later"); | |
| 239 | ||
| 240 | await waitFor(() => { | |
| 241 | expect(getWsConnectionStatus()).toMatchObject({ | |
| 242 | closeReason: "try again later", | |
| 243 | hasConnected: true, | |
| 244 | }); | |
| 245 | }); | |
| 246 | expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("reconnecting"); | |
| 247 | ||
| 248 | await transport.dispose(); | |
| 249 | }); | |
| 250 | ||
| 251 | it("reconnects the websocket session without disposing the transport", async () => { | |
| 252 | const transport = new WsTransport("ws://localhost:3020"); | |
| 253 | ||
| 254 | await waitFor(() => { | |
| 255 | expect(sockets).toHaveLength(1); | |
| 256 | }); | |
| 257 | ||
| 258 | const firstSocket = getSocket(); | |
| 259 | firstSocket.open(); | |
| 260 | ||
| 261 | await waitFor(() => { | |
| 262 | expect(getWsConnectionStatus()).toMatchObject({ | |
| 263 | hasConnected: true, | |
| 264 | phase: "connected", | |
| 265 | }); | |
| 266 | }); | |
| 267 | ||
| 268 | await transport.reconnect(); | |
| 269 | ||
| 270 | await waitFor(() => { | |
| 271 | expect(sockets).toHaveLength(2); | |
| 272 | }); | |
| 273 | ||
| 274 | const secondSocket = getSocket(); | |
| 275 | expect(secondSocket).not.toBe(firstSocket); | |
| 276 | expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); | |
| 277 | ||
| 278 | const requestPromise = transport.request((client) => | |
| 279 | client[WS_METHODS.serverUpsertKeybinding]({ | |
| 280 | command: "terminal.toggle", | |
| 281 | key: "ctrl+k", | |
| 282 | }), | |
| 283 | ); | |
| 284 | ||
| 285 | secondSocket.open(); | |
| 286 | ||
| 287 | await waitFor(() => { | |
| 288 | expect(secondSocket.sent).toHaveLength(1); | |
| 289 | }); | |
| 290 | ||
| 291 | const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string }; | |
| 292 | secondSocket.serverMessage( | |
| 293 | JSON.stringify({ | |
| 294 | _tag: "Exit", | |
| 295 | requestId: requestMessage.id, | |
| 296 | exit: { | |
| 297 | _tag: "Success", | |
| 298 | value: { | |
| 299 | keybindings: [], | |
| 300 | issues: [], | |
| 301 | }, | |
| 302 | }, | |
| 303 | }), | |
| 304 | ); | |
| 305 | ||
| 306 | await expect(requestPromise).resolves.toEqual({ | |
| 307 | keybindings: [], | |
| 308 | issues: [], | |
| 309 | }); | |
| 310 | ||
| 311 | await transport.dispose(); | |
| 312 | }); | |
| 313 | ||
| 314 | it("marks unary requests as slow until the first server ack arrives", async () => { | |
| 315 | const slowAckThresholdMs = 25; | |
| 316 | setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); | |
| 317 | const transport = new WsTransport("ws://localhost:3020"); | |
| 318 | ||
| 319 | const requestPromise = transport.request((client) => | |
| 320 | client[WS_METHODS.serverUpsertKeybinding]({ | |
| 321 | command: "terminal.toggle", | |
| 322 | key: "ctrl+k", | |
| 323 | }), | |
| 324 | ); | |
| 325 | ||
| 326 | await waitFor(() => { | |
| 327 | expect(sockets).toHaveLength(1); | |
| 328 | }); | |
| 329 | ||
| 330 | const socket = getSocket(); | |
| 331 | socket.open(); | |
| 332 | ||
| 333 | await waitFor(() => { | |
| 334 | expect(socket.sent).toHaveLength(1); | |
| 335 | }); | |
| 336 | ||
| 337 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 338 | await waitFor(() => { | |
| 339 | expect(getSlowRpcAckRequests()).toMatchObject([ | |
| 340 | { | |
| 341 | requestId: requestMessage.id, | |
| 342 | tag: WS_METHODS.serverUpsertKeybinding, | |
| 343 | }, | |
| 344 | ]); | |
| 345 | }, 1_000); | |
| 346 | ||
| 347 | socket.serverMessage( | |
| 348 | JSON.stringify({ | |
| 349 | _tag: "Exit", | |
| 350 | requestId: requestMessage.id, | |
| 351 | exit: { | |
| 352 | _tag: "Success", | |
| 353 | value: { | |
| 354 | keybindings: [], | |
| 355 | issues: [], | |
| 356 | }, | |
| 357 | }, | |
| 358 | }), | |
| 359 | ); | |
| 360 | ||
| 361 | await expect(requestPromise).resolves.toEqual({ | |
| 362 | keybindings: [], | |
| 363 | issues: [], | |
| 364 | }); | |
| 365 | expect(getSlowRpcAckRequests()).toEqual([]); | |
| 366 | ||
| 367 | await transport.dispose(); | |
| 368 | }, 5_000); | |
| 369 | ||
| 370 | it("sends unary RPC requests and resolves successful exits", async () => { | |
| 371 | const transport = new WsTransport("ws://localhost:3020"); | |
| 372 | ||
| 373 | const requestPromise = transport.request((client) => | |
| 374 | client[WS_METHODS.serverUpsertKeybinding]({ | |
| 375 | command: "terminal.toggle", | |
| 376 | key: "ctrl+k", | |
| 377 | }), | |
| 378 | ); | |
| 379 | ||
| 380 | await waitFor(() => { | |
| 381 | expect(sockets).toHaveLength(1); | |
| 382 | }); | |
| 383 | ||
| 384 | const socket = getSocket(); | |
| 385 | socket.open(); | |
| 386 | ||
| 387 | await waitFor(() => { | |
| 388 | expect(socket.sent).toHaveLength(1); | |
| 389 | }); | |
| 390 | ||
| 391 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | |
| 392 | _tag: string; | |
| 393 | id: string; | |
| 394 | payload: unknown; | |
| 395 | tag: string; | |
| 396 | }; | |
| 397 | expect(requestMessage).toMatchObject({ | |
| 398 | _tag: "Request", | |
| 399 | tag: WS_METHODS.serverUpsertKeybinding, | |
| 400 | payload: { | |
| 401 | command: "terminal.toggle", | |
| 402 | key: "ctrl+k", | |
| 403 | }, | |
| 404 | }); | |
| 405 | ||
| 406 | socket.serverMessage( | |
| 407 | JSON.stringify({ | |
| 408 | _tag: "Exit", | |
| 409 | requestId: requestMessage.id, | |
| 410 | exit: { | |
| 411 | _tag: "Success", | |
| 412 | value: { | |
| 413 | keybindings: [], | |
| 414 | issues: [], | |
| 415 | }, | |
| 416 | }, | |
| 417 | }), | |
| 418 | ); | |
| 419 | ||
| 420 | await expect(requestPromise).resolves.toEqual({ | |
| 421 | keybindings: [], | |
| 422 | issues: [], | |
| 423 | }); | |
| 424 | ||
| 425 | await transport.dispose(); | |
| 426 | }); | |
| 427 | ||
| 428 | it("delivers stream chunks to subscribers", async () => { | |
| 429 | const transport = new WsTransport("ws://localhost:3020"); | |
| 430 | const listener = vi.fn(); | |
| 431 | ||
| 432 | const unsubscribe = transport.subscribe( | |
| 433 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | |
| 434 | listener, | |
| 435 | ); | |
| 436 | await waitFor(() => { | |
| 437 | expect(sockets).toHaveLength(1); | |
| 438 | }); | |
| 439 | ||
| 440 | const socket = getSocket(); | |
| 441 | socket.open(); | |
| 442 | ||
| 443 | await waitFor(() => { | |
| 444 | expect(socket.sent).toHaveLength(1); | |
| 445 | }); | |
| 446 | ||
| 447 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | |
| 448 | expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); | |
| 449 | ||
| 450 | const welcomeEvent = { | |
| 451 | version: 1, | |
| 452 | sequence: 1, | |
| 453 | type: "welcome", | |
| 454 | payload: { | |
| 455 | environment: { | |
| 456 | environmentId: "environment-local", | |
| 457 | label: "Local environment", | |
| 458 | platform: { os: "darwin", arch: "arm64" }, | |
| 459 | serverVersion: "0.0.0-test", | |
| 460 | capabilities: { repositoryIdentity: true }, | |
| 461 | }, | |
| 462 | cwd: "/tmp/workspace", | |
| 463 | projectName: "workspace", | |
| 464 | }, | |
| 465 | }; | |
| 466 | ||
| 467 | socket.serverMessage( | |
| 468 | JSON.stringify({ | |
| 469 | _tag: "Chunk", | |
| 470 | requestId: requestMessage.id, | |
| 471 | values: [welcomeEvent], | |
| 472 | }), | |
| 473 | ); | |
| 474 | ||
| 475 | await waitFor(() => { | |
| 476 | expect(listener).toHaveBeenCalledWith(welcomeEvent); | |
| 477 | }); | |
| 478 | ||
| 479 | unsubscribe(); | |
| 480 | await transport.dispose(); | |
| 481 | }); | |
| 482 | ||
| 483 | it("re-subscribes stream listeners after the stream exits", async () => { | |
| 484 | const transport = new WsTransport("ws://localhost:3020"); | |
| 485 | const listener = vi.fn(); | |
| 486 | const onResubscribe = vi.fn(); | |
| 487 | ||
| 488 | const unsubscribe = transport.subscribe( | |
| 489 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | |
| 490 | listener, | |
| 491 | { onResubscribe }, | |
| 492 | ); | |
| 493 | await waitFor(() => { | |
| 494 | expect(sockets).toHaveLength(1); | |
| 495 | }); | |
| 496 | ||
| 497 | const socket = getSocket(); | |
| 498 | socket.open(); | |
| 499 | ||
| 500 | await waitFor(() => { | |
| 501 | expect(socket.sent).toHaveLength(1); | |
| 502 | }); | |
| 503 | ||
| 504 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 505 | socket.serverMessage( | |
| 506 | JSON.stringify({ | |
| 507 | _tag: "Chunk", | |
| 508 | requestId: firstRequest.id, | |
| 509 | values: [ | |
| 510 | { | |
| 511 | version: 1, | |
| 512 | sequence: 1, | |
| 513 | type: "welcome", | |
| 514 | payload: { | |
| 515 | environment: { | |
| 516 | environmentId: "environment-local", | |
| 517 | label: "Local environment", | |
| 518 | platform: { os: "darwin", arch: "arm64" }, | |
| 519 | serverVersion: "0.0.0-test", | |
| 520 | capabilities: { repositoryIdentity: true }, | |
| 521 | }, | |
| 522 | cwd: "/tmp/one", | |
| 523 | projectName: "one", | |
| 524 | }, | |
| 525 | }, | |
| 526 | ], | |
| 527 | }), | |
| 528 | ); | |
| 529 | socket.serverMessage( | |
| 530 | JSON.stringify({ | |
| 531 | _tag: "Exit", | |
| 532 | requestId: firstRequest.id, | |
| 533 | exit: { | |
| 534 | _tag: "Success", | |
| 535 | value: null, | |
| 536 | }, | |
| 537 | }), | |
| 538 | ); | |
| 539 | ||
| 540 | await waitFor(() => { | |
| 541 | const nextRequest = socket.sent | |
| 542 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | |
| 543 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | |
| 544 | expect(nextRequest).toBeDefined(); | |
| 545 | }); | |
| 546 | expect(onResubscribe).toHaveBeenCalledOnce(); | |
| 547 | ||
| 548 | const secondRequest = socket.sent | |
| 549 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) | |
| 550 | .find( | |
| 551 | (message): message is { _tag: "Request"; id: string; tag: string } => | |
| 552 | message._tag === "Request" && message.id !== firstRequest.id, | |
| 553 | ); | |
| 554 | if (!secondRequest) { | |
| 555 | throw new Error("Expected a resubscribe request"); | |
| 556 | } | |
| 557 | expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); | |
| 558 | expect(secondRequest.id).not.toBe(firstRequest.id); | |
| 559 | ||
| 560 | const secondEvent = { | |
| 561 | version: 1, | |
| 562 | sequence: 2, | |
| 563 | type: "welcome", | |
| 564 | payload: { | |
| 565 | environment: { | |
| 566 | environmentId: "environment-local", | |
| 567 | label: "Local environment", | |
| 568 | platform: { os: "darwin", arch: "arm64" }, | |
| 569 | serverVersion: "0.0.0-test", | |
| 570 | capabilities: { repositoryIdentity: true }, | |
| 571 | }, | |
| 572 | cwd: "/tmp/two", | |
| 573 | projectName: "two", | |
| 574 | }, | |
| 575 | }; | |
| 576 | socket.serverMessage( | |
| 577 | JSON.stringify({ | |
| 578 | _tag: "Chunk", | |
| 579 | requestId: secondRequest.id, | |
| 580 | values: [secondEvent], | |
| 581 | }), | |
| 582 | ); | |
| 583 | ||
| 584 | await waitFor(() => { | |
| 585 | expect(listener).toHaveBeenLastCalledWith(secondEvent); | |
| 586 | }); | |
| 587 | ||
| 588 | unsubscribe(); | |
| 589 | await transport.dispose(); | |
| 590 | }); | |
| 591 | ||
| 592 | it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { | |
| 593 | const transport = new WsTransport("ws://localhost:3020"); | |
| 594 | const listener = vi.fn(); | |
| 595 | const onResubscribe = vi.fn(); | |
| 596 | ||
| 597 | const unsubscribe = transport.subscribe( | |
| 598 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | |
| 599 | listener, | |
| 600 | { onResubscribe }, | |
| 601 | ); | |
| 602 | await waitFor(() => { | |
| 603 | expect(sockets).toHaveLength(1); | |
| 604 | }); | |
| 605 | ||
| 606 | const socket = getSocket(); | |
| 607 | socket.open(); | |
| 608 | ||
| 609 | await waitFor(() => { | |
| 610 | expect(socket.sent).toHaveLength(1); | |
| 611 | }); | |
| 612 | ||
| 613 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 614 | socket.serverMessage( | |
| 615 | JSON.stringify({ | |
| 616 | _tag: "Exit", | |
| 617 | requestId: firstRequest.id, | |
| 618 | exit: { | |
| 619 | _tag: "Success", | |
| 620 | value: null, | |
| 621 | }, | |
| 622 | }), | |
| 623 | ); | |
| 624 | ||
| 625 | await waitFor(() => { | |
| 626 | const nextRequest = socket.sent | |
| 627 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | |
| 628 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | |
| 629 | expect(nextRequest).toBeDefined(); | |
| 630 | }); | |
| 631 | expect(onResubscribe).not.toHaveBeenCalled(); | |
| 632 | expect(listener).not.toHaveBeenCalled(); | |
| 633 | ||
| 634 | unsubscribe(); | |
| 635 | await transport.dispose(); | |
| 636 | }); | |
| 637 | ||
| 638 | it("does not retry stream subscriptions after application-level failures", async () => { | |
| 639 | const transport = new WsTransport("ws://localhost:3020"); | |
| 640 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | |
| 641 | let attempts = 0; | |
| 642 | ||
| 643 | const unsubscribe = transport.subscribe( | |
| 644 | () => | |
| 645 | Stream.suspend(() => { | |
| 646 | attempts += 1; | |
| 647 | return Stream.fail(new Error("Git command failed in GitCore.statusDetails")); | |
| 648 | }), | |
| 649 | vi.fn(), | |
| 650 | { retryDelay: 10 }, | |
| 651 | ); | |
| 652 | ||
| 653 | await waitFor(() => { | |
| 654 | expect(sockets).toHaveLength(1); | |
| 655 | }); | |
| 656 | ||
| 657 | getSocket().open(); | |
| 658 | ||
| 659 | await waitFor(() => { | |
| 660 | expect(attempts).toBe(1); | |
| 661 | }); | |
| 662 | await new Promise((resolve) => setTimeout(resolve, 50)); | |
| 663 | ||
| 664 | expect(attempts).toBe(1); | |
| 665 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription failed", { | |
| 666 | error: "Git command failed in GitCore.statusDetails", | |
| 667 | }); | |
| 668 | expect(warnSpy).not.toHaveBeenCalledWith( | |
| 669 | "WebSocket RPC subscription disconnected", | |
| 670 | expect.anything(), | |
| 671 | ); | |
| 672 | ||
| 673 | unsubscribe(); | |
| 674 | await transport.dispose(); | |
| 675 | }); | |
| 676 | ||
| 677 | it("keeps retrying stream subscriptions after transport failures", async () => { | |
| 678 | const transport = new WsTransport("ws://localhost:3020"); | |
| 679 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | |
| 680 | let attempts = 0; | |
| 681 | ||
| 682 | const unsubscribe = transport.subscribe( | |
| 683 | () => | |
| 684 | Stream.suspend(() => { | |
| 685 | attempts += 1; | |
| 686 | return Stream.fail(new Error("SocketCloseError: WebSocket closed")); | |
| 687 | }), | |
| 688 | vi.fn(), | |
| 689 | { retryDelay: 10 }, | |
| 690 | ); | |
| 691 | ||
| 692 | await waitFor(() => { | |
| 693 | expect(sockets).toHaveLength(1); | |
| 694 | }); | |
| 695 | ||
| 696 | getSocket().open(); | |
| 697 | ||
| 698 | await waitFor(() => { | |
| 699 | expect(attempts).toBeGreaterThanOrEqual(2); | |
| 700 | }); | |
| 701 | ||
| 702 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { | |
| 703 | error: "SocketCloseError: WebSocket closed", | |
| 704 | }); | |
| 705 | ||
| 706 | unsubscribe(); | |
| 707 | await transport.dispose(); | |
| 708 | }); | |
| 709 | ||
| 710 | it("streams finite request events without re-subscribing", async () => { | |
| 711 | const transport = new WsTransport("ws://localhost:3020"); | |
| 712 | const listener = vi.fn(); | |
| 713 | ||
| 714 | await waitFor(() => { | |
| 715 | expect(sockets).toHaveLength(1); | |
| 716 | }); | |
| 717 | const socket = getSocket(); | |
| 718 | socket.open(); | |
| 719 | ||
| 720 | const requestPromise = transport.requestStream( | |
| 721 | (client) => | |
| 722 | client[WS_METHODS.gitRunStackedAction]({ | |
| 723 | actionId: "action-1", | |
| 724 | cwd: "/repo", | |
| 725 | action: "commit", | |
| 726 | }), | |
| 727 | listener, | |
| 728 | ); | |
| 729 | ||
| 730 | await waitFor(() => { | |
| 731 | expect(socket.sent).toHaveLength(1); | |
| 732 | }); | |
| 733 | ||
| 734 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 735 | const progressEvent = { | |
| 736 | actionId: "action-1", | |
| 737 | cwd: "/repo", | |
| 738 | action: "commit", | |
| 739 | kind: "phase_started", | |
| 740 | phase: "commit", | |
| 741 | label: "Committing...", | |
| 742 | } as const; | |
| 743 | ||
| 744 | socket.serverMessage( | |
| 745 | JSON.stringify({ | |
| 746 | _tag: "Chunk", | |
| 747 | requestId: requestMessage.id, | |
| 748 | values: [progressEvent], | |
| 749 | }), | |
| 750 | ); | |
| 751 | socket.serverMessage( | |
| 752 | JSON.stringify({ | |
| 753 | _tag: "Exit", | |
| 754 | requestId: requestMessage.id, | |
| 755 | exit: { | |
| 756 | _tag: "Success", | |
| 757 | value: null, | |
| 758 | }, | |
| 759 | }), | |
| 760 | ); | |
| 761 | ||
| 762 | await expect(requestPromise).resolves.toBeUndefined(); | |
| 763 | expect(listener).toHaveBeenCalledWith(progressEvent); | |
| 764 | expect( | |
| 765 | socket.sent.filter((message) => { | |
| 766 | const parsed = JSON.parse(message) as { _tag?: string; tag?: string }; | |
| 767 | return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction; | |
| 768 | }), | |
| 769 | ).toHaveLength(1); | |
| 770 | await transport.dispose(); | |
| 771 | }); | |
| 772 | ||
| 773 | it("closes the client scope on the transport runtime before disposing the runtime", async () => { | |
| 774 | const callOrder: string[] = []; | |
| 775 | let resolveClose!: () => void; | |
| 776 | const closePromise = new Promise<void>((resolve) => { | |
| 777 | resolveClose = resolve; | |
| 778 | }); | |
| 779 | ||
| 780 | const runtime = { | |
| 781 | runPromise: vi.fn(async () => { | |
| 782 | callOrder.push("close:start"); | |
| 783 | await closePromise; | |
| 784 | callOrder.push("close:done"); | |
| 785 | return undefined; | |
| 786 | }), | |
| 787 | dispose: vi.fn(async () => { | |
| 788 | callOrder.push("runtime:dispose"); | |
| 789 | }), | |
| 790 | }; | |
| 791 | const transport = { | |
| 792 | disposed: false, | |
| 793 | session: { | |
| 794 | clientScope: {} as never, | |
| 795 | runtime, | |
| 796 | }, | |
| 797 | closeSession: ( | |
| 798 | WsTransport.prototype as unknown as { | |
| 799 | closeSession: (session: { | |
| 800 | clientScope: unknown; | |
| 801 | runtime: { dispose: () => Promise<void>; runPromise: () => Promise<void> }; | |
| 802 | }) => Promise<void>; | |
| 803 | } | |
| 804 | ).closeSession, | |
| 805 | } as unknown as WsTransport; | |
| 806 | ||
| 807 | void WsTransport.prototype.dispose.call(transport); | |
| 808 | ||
| 809 | expect(runtime.runPromise).toHaveBeenCalledTimes(1); | |
| 810 | expect(runtime.dispose).not.toHaveBeenCalled(); | |
| 811 | expect((transport as unknown as { disposed: boolean }).disposed).toBe(true); | |
| 812 | ||
| 813 | resolveClose(); | |
| 814 | ||
| 815 | await waitFor(() => { | |
| 816 | expect(runtime.dispose).toHaveBeenCalledTimes(1); | |
| 817 | }); | |
| 818 | ||
| 819 | expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); | |
| 820 | }); | |
| 821 | ||
| 822 | it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => { | |
| 823 | await configureClientTracing({ | |
| 824 | exportIntervalMs: 10, | |
| 825 | }); | |
| 826 | ||
| 827 | const transport = new WsTransport("ws://localhost:3020"); | |
| 828 | const requestPromise = transport.request((client) => client[WS_METHODS.serverGetSettings]({})); | |
| 829 | ||
| 830 | await waitFor(() => { | |
| 831 | expect(sockets).toHaveLength(1); | |
| 832 | }); | |
| 833 | ||
| 834 | const socket = getSocket(); | |
| 835 | socket.open(); | |
| 836 | ||
| 837 | await waitFor(() => { | |
| 838 | expect(socket.sent).toHaveLength(1); | |
| 839 | }); | |
| 840 | ||
| 841 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | |
| 842 | id: string; | |
| 843 | spanId?: string; | |
| 844 | traceId?: string; | |
| 845 | }; | |
| 846 | expect(requestMessage.traceId).toMatch(/^[0-9a-f]{32}$/); | |
| 847 | expect(requestMessage.spanId).toMatch(/^[0-9a-f]{16}$/); | |
| 848 | ||
| 849 | socket.serverMessage( | |
| 850 | JSON.stringify({ | |
| 851 | _tag: "Exit", | |
| 852 | requestId: requestMessage.id, | |
| 853 | exit: { | |
| 854 | _tag: "Success", | |
| 855 | value: DEFAULT_SERVER_SETTINGS, | |
| 856 | }, | |
| 857 | }), | |
| 858 | ); | |
| 859 | ||
| 860 | await expect(requestPromise).resolves.toEqual(DEFAULT_SERVER_SETTINGS); | |
| 861 | await transport.dispose(); | |
| 862 | }); | |
| 863 | }); |
apps/web/src/rpc/wsTransport.ts created+276| ... | ... | @@ -0,0 +1,276 @@ |
| 1 | import { | |
| 2 | Cause, | |
| 3 | Duration, | |
| 4 | Effect, | |
| 5 | Exit, | |
| 6 | Layer, | |
| 7 | ManagedRuntime, | |
| 8 | Option, | |
| 9 | Scope, | |
| 10 | Stream, | |
| 11 | } from "effect"; | |
| 12 | import { RpcClient } from "effect/unstable/rpc"; | |
| 13 | ||
| 14 | import { ClientTracingLive } from "../observability/clientTracing"; | |
| 15 | import { | |
| 16 | createWsRpcProtocolLayer, | |
| 17 | makeWsRpcProtocolClient, | |
| 18 | type WsProtocolLifecycleHandlers, | |
| 19 | type WsRpcProtocolClient, | |
| 20 | type WsRpcProtocolSocketUrlProvider, | |
| 21 | } from "./protocol"; | |
| 22 | import { isTransportConnectionErrorMessage } from "./transportError"; | |
| 23 | ||
| 24 | interface SubscribeOptions { | |
| 25 | readonly retryDelay?: Duration.Input; | |
| 26 | readonly onResubscribe?: () => void; | |
| 27 | } | |
| 28 | ||
| 29 | interface RequestOptions { | |
| 30 | readonly timeout?: Option.Option<Duration.Input>; | |
| 31 | } | |
| 32 | ||
| 33 | const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250); | |
| 34 | const NOOP: () => void = () => undefined; | |
| 35 | ||
| 36 | interface TransportSession { | |
| 37 | readonly clientPromise: Promise<WsRpcProtocolClient>; | |
| 38 | readonly clientScope: Scope.Closeable; | |
| 39 | readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>; | |
| 40 | } | |
| 41 | ||
| 42 | function formatErrorMessage(error: unknown): string { | |
| 43 | if (error instanceof Error && error.message.trim().length > 0) { | |
| 44 | return error.message; | |
| 45 | } | |
| 46 | return String(error); | |
| 47 | } | |
| 48 | ||
| 49 | export class WsTransport { | |
| 50 | private readonly url: WsRpcProtocolSocketUrlProvider; | |
| 51 | private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; | |
| 52 | private disposed = false; | |
| 53 | private reconnectChain: Promise<void> = Promise.resolve(); | |
| 54 | private session: TransportSession; | |
| 55 | ||
| 56 | constructor( | |
| 57 | url: WsRpcProtocolSocketUrlProvider, | |
| 58 | lifecycleHandlers?: WsProtocolLifecycleHandlers, | |
| 59 | ) { | |
| 60 | this.url = url; | |
| 61 | this.lifecycleHandlers = lifecycleHandlers; | |
| 62 | this.session = this.createSession(); | |
| 63 | } | |
| 64 | ||
| 65 | async request<TSuccess>( | |
| 66 | execute: (client: WsRpcProtocolClient) => Effect.Effect<TSuccess, Error, never>, | |
| 67 | _options?: RequestOptions, | |
| 68 | ): Promise<TSuccess> { | |
| 69 | if (this.disposed) { | |
| 70 | throw new Error("Transport disposed"); | |
| 71 | } | |
| 72 | ||
| 73 | const session = this.session; | |
| 74 | const client = await session.clientPromise; | |
| 75 | return await session.runtime.runPromise(Effect.suspend(() => execute(client))); | |
| 76 | } | |
| 77 | ||
| 78 | async requestStream<TValue>( | |
| 79 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | |
| 80 | listener: (value: TValue) => void, | |
| 81 | ): Promise<void> { | |
| 82 | if (this.disposed) { | |
| 83 | throw new Error("Transport disposed"); | |
| 84 | } | |
| 85 | ||
| 86 | const session = this.session; | |
| 87 | const client = await session.clientPromise; | |
| 88 | await session.runtime.runPromise( | |
| 89 | Stream.runForEach(connect(client), (value) => | |
| 90 | Effect.sync(() => { | |
| 91 | try { | |
| 92 | listener(value); | |
| 93 | } catch { | |
| 94 | // Swallow listener errors so the stream can finish cleanly. | |
| 95 | } | |
| 96 | }), | |
| 97 | ), | |
| 98 | ); | |
| 99 | } | |
| 100 | ||
| 101 | subscribe<TValue>( | |
| 102 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | |
| 103 | listener: (value: TValue) => void, | |
| 104 | options?: SubscribeOptions, | |
| 105 | ): () => void { | |
| 106 | if (this.disposed) { | |
| 107 | return () => undefined; | |
| 108 | } | |
| 109 | ||
| 110 | let active = true; | |
| 111 | let hasReceivedValue = false; | |
| 112 | const retryDelayMs = Duration.toMillis( | |
| 113 | Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS), | |
| 114 | ); | |
| 115 | let cancelCurrentStream: () => void = NOOP; | |
| 116 | ||
| 117 | void (async () => { | |
| 118 | for (;;) { | |
| 119 | if (!active || this.disposed) { | |
| 120 | return; | |
| 121 | } | |
| 122 | ||
| 123 | try { | |
| 124 | if (hasReceivedValue) { | |
| 125 | try { | |
| 126 | options?.onResubscribe?.(); | |
| 127 | } catch { | |
| 128 | // Swallow reconnect hook errors so the stream can recover. | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | const session = this.session; | |
| 133 | const runningStream = this.runStreamOnSession( | |
| 134 | session, | |
| 135 | connect, | |
| 136 | listener, | |
| 137 | () => active, | |
| 138 | () => { | |
| 139 | hasReceivedValue = true; | |
| 140 | }, | |
| 141 | ); | |
| 142 | cancelCurrentStream = runningStream.cancel; | |
| 143 | await runningStream.completed; | |
| 144 | cancelCurrentStream = NOOP; | |
| 145 | } catch (error) { | |
| 146 | cancelCurrentStream = NOOP; | |
| 147 | if (!active || this.disposed) { | |
| 148 | return; | |
| 149 | } | |
| 150 | ||
| 151 | const formattedError = formatErrorMessage(error); | |
| 152 | if (!isTransportConnectionErrorMessage(formattedError)) { | |
| 153 | console.warn("WebSocket RPC subscription failed", { | |
| 154 | error: formattedError, | |
| 155 | }); | |
| 156 | return; | |
| 157 | } | |
| 158 | ||
| 159 | console.warn("WebSocket RPC subscription disconnected", { | |
| 160 | error: formattedError, | |
| 161 | }); | |
| 162 | await sleep(retryDelayMs); | |
| 163 | } | |
| 164 | } | |
| 165 | })(); | |
| 166 | ||
| 167 | return () => { | |
| 168 | active = false; | |
| 169 | cancelCurrentStream(); | |
| 170 | }; | |
| 171 | } | |
| 172 | ||
| 173 | async reconnect() { | |
| 174 | if (this.disposed) { | |
| 175 | throw new Error("Transport disposed"); | |
| 176 | } | |
| 177 | ||
| 178 | const reconnectOperation = this.reconnectChain.then(async () => { | |
| 179 | if (this.disposed) { | |
| 180 | throw new Error("Transport disposed"); | |
| 181 | } | |
| 182 | ||
| 183 | const previousSession = this.session; | |
| 184 | this.session = this.createSession(); | |
| 185 | await this.closeSession(previousSession); | |
| 186 | }); | |
| 187 | ||
| 188 | this.reconnectChain = reconnectOperation.catch(() => undefined); | |
| 189 | await reconnectOperation; | |
| 190 | } | |
| 191 | ||
| 192 | async dispose() { | |
| 193 | if (this.disposed) { | |
| 194 | return; | |
| 195 | } | |
| 196 | this.disposed = true; | |
| 197 | await this.closeSession(this.session); | |
| 198 | } | |
| 199 | ||
| 200 | private closeSession(session: TransportSession) { | |
| 201 | return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { | |
| 202 | session.runtime.dispose(); | |
| 203 | }); | |
| 204 | } | |
| 205 | ||
| 206 | private createSession(): TransportSession { | |
| 207 | const runtime = ManagedRuntime.make( | |
| 208 | Layer.mergeAll(createWsRpcProtocolLayer(this.url, this.lifecycleHandlers), ClientTracingLive), | |
| 209 | ); | |
| 210 | const clientScope = runtime.runSync(Scope.make()); | |
| 211 | return { | |
| 212 | runtime, | |
| 213 | clientScope, | |
| 214 | clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)), | |
| 215 | }; | |
| 216 | } | |
| 217 | ||
| 218 | private runStreamOnSession<TValue>( | |
| 219 | session: TransportSession, | |
| 220 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | |
| 221 | listener: (value: TValue) => void, | |
| 222 | isActive: () => boolean, | |
| 223 | markValueReceived: () => void, | |
| 224 | ): { | |
| 225 | readonly cancel: () => void; | |
| 226 | readonly completed: Promise<void>; | |
| 227 | } { | |
| 228 | let resolveCompleted!: () => void; | |
| 229 | let rejectCompleted!: (error: unknown) => void; | |
| 230 | const completed = new Promise<void>((resolve, reject) => { | |
| 231 | resolveCompleted = resolve; | |
| 232 | rejectCompleted = reject; | |
| 233 | }); | |
| 234 | const cancel = session.runtime.runCallback( | |
| 235 | Effect.promise(() => session.clientPromise).pipe( | |
| 236 | Effect.flatMap((client) => | |
| 237 | Stream.runForEach(connect(client), (value) => | |
| 238 | Effect.sync(() => { | |
| 239 | if (!isActive()) { | |
| 240 | return; | |
| 241 | } | |
| 242 | ||
| 243 | markValueReceived(); | |
| 244 | try { | |
| 245 | listener(value); | |
| 246 | } catch { | |
| 247 | // Swallow listener errors so the stream stays live. | |
| 248 | } | |
| 249 | }), | |
| 250 | ), | |
| 251 | ), | |
| 252 | ), | |
| 253 | { | |
| 254 | onExit: (exit) => { | |
| 255 | if (Exit.isSuccess(exit)) { | |
| 256 | resolveCompleted(); | |
| 257 | return; | |
| 258 | } | |
| 259 | ||
| 260 | rejectCompleted(Cause.squash(exit.cause)); | |
| 261 | }, | |
| 262 | }, | |
| 263 | ); | |
| 264 | ||
| 265 | return { | |
| 266 | cancel, | |
| 267 | completed, | |
| 268 | }; | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | function sleep(ms: number): Promise<void> { | |
| 273 | return new Promise((resolve) => { | |
| 274 | setTimeout(resolve, ms); | |
| 275 | }); | |
| 276 | } |
apps/web/src/store.ts+44-49| ... | ... | @@ -1,21 +1,21 @@ |
| 1 | import { | |
| 2 | type EnvironmentId, | |
| 3 | type MessageId, | |
| 4 | type OrchestrationCheckpointSummary, | |
| 5 | type OrchestrationEvent, | |
| 6 | type OrchestrationMessage, | |
| 7 | type OrchestrationProposedPlan, | |
| 8 | type OrchestrationReadModel, | |
| 9 | type OrchestrationSession, | |
| 10 | type OrchestrationSessionStatus, | |
| 11 | type OrchestrationThread, | |
| 12 | type OrchestrationThreadActivity, | |
| 13 | type ProjectId, | |
| 14 | type ProviderKind, | |
| 15 | type ScopedProjectRef, | |
| 16 | type ScopedThreadRef, | |
| 1 | import type { | |
| 2 | EnvironmentId, | |
| 3 | MessageId, | |
| 4 | OrchestrationCheckpointSummary, | |
| 5 | OrchestrationEvent, | |
| 6 | OrchestrationMessage, | |
| 7 | OrchestrationProposedPlan, | |
| 8 | OrchestrationReadModel, | |
| 9 | OrchestrationSession, | |
| 10 | OrchestrationSessionStatus, | |
| 11 | OrchestrationThread, | |
| 12 | OrchestrationThreadActivity, | |
| 13 | ProjectId, | |
| 14 | ProviderKind, | |
| 15 | ScopedProjectRef, | |
| 16 | ScopedThreadRef, | |
| 17 | 17 | ThreadId, |
| 18 | type TurnId, | |
| 18 | TurnId, | |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | 20 | import { resolveModelSlugForProvider } from "@t3tools/shared/model"; |
| 21 | 21 | import { create } from "zustand"; |
| ... | ... | @@ -36,6 +36,7 @@ import { |
| 36 | 36 | type ThreadTurnState, |
| 37 | 37 | type TurnDiffSummary, |
| 38 | 38 | } from "./types"; |
| 39 | import { resolveEnvironmentHttpUrl } from "./environments/runtime"; | |
| 39 | 40 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; |
| 40 | 41 | |
| 41 | 42 | export interface EnvironmentState { |
| ... | ... | @@ -136,14 +137,17 @@ function mapSession(session: OrchestrationSession): ThreadSession { |
| 136 | 137 | }; |
| 137 | 138 | } |
| 138 | 139 | |
| 139 | function mapMessage(message: OrchestrationMessage): ChatMessage { | |
| 140 | function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage): ChatMessage { | |
| 140 | 141 | const attachments = message.attachments?.map((attachment) => ({ |
| 141 | 142 | type: "image" as const, |
| 142 | 143 | id: attachment.id, |
| 143 | 144 | name: attachment.name, |
| 144 | 145 | mimeType: attachment.mimeType, |
| 145 | 146 | sizeBytes: attachment.sizeBytes, |
| 146 | previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), | |
| 147 | previewUrl: resolveEnvironmentHttpUrl({ | |
| 148 | environmentId, | |
| 149 | pathname: attachmentPreviewRoutePath(attachment.id), | |
| 150 | }), | |
| 147 | 151 | })); |
| 148 | 152 | |
| 149 | 153 | return { |
| ... | ... | @@ -212,7 +216,7 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T |
| 212 | 216 | runtimeMode: thread.runtimeMode, |
| 213 | 217 | interactionMode: thread.interactionMode, |
| 214 | 218 | session: thread.session ? mapSession(thread.session) : null, |
| 215 | messages: thread.messages.map(mapMessage), | |
| 219 | messages: thread.messages.map((message) => mapMessage(environmentId, message)), | |
| 216 | 220 | proposedPlans: thread.proposedPlans.map(mapProposedPlan), |
| 217 | 221 | error: sanitizeThreadErrorMessage(thread.session?.lastError), |
| 218 | 222 | createdAt: thread.createdAt, |
| ... | ... | @@ -883,34 +887,6 @@ function toLegacyProvider(providerName: string | null): ProviderKind { |
| 883 | 887 | return "codex"; |
| 884 | 888 | } |
| 885 | 889 | |
| 886 | function resolveWsHttpOrigin(): string { | |
| 887 | if (typeof window === "undefined") return ""; | |
| 888 | const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); | |
| 889 | const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; | |
| 890 | const wsCandidate = | |
| 891 | typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 | |
| 892 | ? bridgeWsUrl | |
| 893 | : typeof envWsUrl === "string" && envWsUrl.length > 0 | |
| 894 | ? envWsUrl | |
| 895 | : null; | |
| 896 | if (!wsCandidate) return window.location.origin; | |
| 897 | try { | |
| 898 | const wsUrl = new URL(wsCandidate); | |
| 899 | const protocol = | |
| 900 | wsUrl.protocol === "wss:" ? "https:" : wsUrl.protocol === "ws:" ? "http:" : wsUrl.protocol; | |
| 901 | return `${protocol}//${wsUrl.host}`; | |
| 902 | } catch { | |
| 903 | return window.location.origin; | |
| 904 | } | |
| 905 | } | |
| 906 | ||
| 907 | function toAttachmentPreviewUrl(rawUrl: string): string { | |
| 908 | if (rawUrl.startsWith("/")) { | |
| 909 | return `${resolveWsHttpOrigin()}${rawUrl}`; | |
| 910 | } | |
| 911 | return rawUrl; | |
| 912 | } | |
| 913 | ||
| 914 | 890 | function attachmentPreviewRoutePath(attachmentId: string): string { |
| 915 | 891 | return `/attachments/${encodeURIComponent(attachmentId)}`; |
| 916 | 892 | } |
| ... | ... | @@ -1298,7 +1274,7 @@ function applyEnvironmentOrchestrationEvent( |
| 1298 | 1274 | |
| 1299 | 1275 | case "thread.message-sent": |
| 1300 | 1276 | return updateThreadState(state, event.payload.threadId, (thread) => { |
| 1301 | const message = mapMessage({ | |
| 1277 | const message = mapMessage(thread.environmentId, { | |
| 1302 | 1278 | id: event.payload.messageId, |
| 1303 | 1279 | role: event.payload.role, |
| 1304 | 1280 | text: event.payload.text, |
| ... | ... | @@ -1631,6 +1607,16 @@ export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { |
| 1631 | 1607 | ); |
| 1632 | 1608 | } |
| 1633 | 1609 | |
| 1610 | /** Like `selectThreadsAcrossEnvironments` but returns stable `ThreadShell` references from the store (no derived data). */ | |
| 1611 | export function selectThreadShellsAcrossEnvironments(state: AppState): ThreadShell[] { | |
| 1612 | return getEnvironmentEntries(state).flatMap(([, environmentState]) => | |
| 1613 | environmentState.threadIds.flatMap((threadId) => { | |
| 1614 | const shell = environmentState.threadShellById[threadId]; | |
| 1615 | return shell ? [shell] : []; | |
| 1616 | }), | |
| 1617 | ); | |
| 1618 | } | |
| 1619 | ||
| 1634 | 1620 | export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { |
| 1635 | 1621 | return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => |
| 1636 | 1622 | environmentState.threadIds.flatMap((threadId) => { |
| ... | ... | @@ -1656,6 +1642,15 @@ export function selectSidebarThreadsForProjectRef( |
| 1656 | 1642 | }); |
| 1657 | 1643 | } |
| 1658 | 1644 | |
| 1645 | export function selectSidebarThreadsForProjectRefs( | |
| 1646 | state: AppState, | |
| 1647 | refs: readonly ScopedProjectRef[], | |
| 1648 | ): SidebarThreadSummary[] { | |
| 1649 | if (refs.length === 0) return []; | |
| 1650 | if (refs.length === 1) return selectSidebarThreadsForProjectRef(state, refs[0]); | |
| 1651 | return refs.flatMap((ref) => selectSidebarThreadsForProjectRef(state, ref)); | |
| 1652 | } | |
| 1653 | ||
| 1659 | 1654 | export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { |
| 1660 | 1655 | return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete; |
| 1661 | 1656 | } |
apps/web/src/timestampFormat.test.ts+86-2| ... | ... | @@ -1,6 +1,11 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 2 | 2 | |
| 3 | import { getTimestampFormatOptions } from "./timestampFormat"; | |
| 3 | import { | |
| 4 | formatElapsedDurationLabel, | |
| 5 | formatExpiresInLabel, | |
| 6 | formatRelativeTimeUntilLabel, | |
| 7 | getTimestampFormatOptions, | |
| 8 | } from "./timestampFormat"; | |
| 4 | 9 | |
| 5 | 10 | describe("getTimestampFormatOptions", () => { |
| 6 | 11 | it("omits hour12 when locale formatting is requested", () => { |
| ... | ... | @@ -28,3 +33,82 @@ describe("getTimestampFormatOptions", () => { |
| 28 | 33 | }); |
| 29 | 34 | }); |
| 30 | 35 | }); |
| 36 | ||
| 37 | describe("formatRelativeTimeUntilLabel", () => { | |
| 38 | beforeEach(() => { | |
| 39 | vi.useFakeTimers(); | |
| 40 | vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); | |
| 41 | }); | |
| 42 | ||
| 43 | afterEach(() => { | |
| 44 | vi.useRealTimers(); | |
| 45 | }); | |
| 46 | ||
| 47 | it("returns Expired when the instant is in the past", () => { | |
| 48 | expect(formatRelativeTimeUntilLabel("2026-04-07T11:59:00.000Z")).toBe("Expired"); | |
| 49 | }); | |
| 50 | ||
| 51 | it("formats seconds remaining", () => { | |
| 52 | expect(formatRelativeTimeUntilLabel("2026-04-07T12:00:45.000Z")).toBe("45s left"); | |
| 53 | }); | |
| 54 | ||
| 55 | it("formats minutes remaining", () => { | |
| 56 | expect(formatRelativeTimeUntilLabel("2026-04-07T12:15:00.000Z")).toBe("15m left"); | |
| 57 | }); | |
| 58 | ||
| 59 | it("formats hours remaining", () => { | |
| 60 | expect(formatRelativeTimeUntilLabel("2026-04-07T18:00:00.000Z")).toBe("6h left"); | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 64 | describe("formatExpiresInLabel", () => { | |
| 65 | beforeEach(() => { | |
| 66 | vi.useFakeTimers(); | |
| 67 | vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); | |
| 68 | }); | |
| 69 | ||
| 70 | afterEach(() => { | |
| 71 | vi.useRealTimers(); | |
| 72 | }); | |
| 73 | ||
| 74 | it("returns Expired when the instant is in the past", () => { | |
| 75 | expect(formatExpiresInLabel("2026-04-07T11:59:00.000Z")).toBe("Expired"); | |
| 76 | }); | |
| 77 | ||
| 78 | it("uses sub-minute second count", () => { | |
| 79 | expect(formatExpiresInLabel("2026-04-07T12:00:45.000Z")).toBe("Expires in 45s"); | |
| 80 | }); | |
| 81 | ||
| 82 | it("uses minutes and seconds under one hour", () => { | |
| 83 | expect(formatExpiresInLabel("2026-04-07T12:04:12.000Z")).toBe("Expires in 4m 12s"); | |
| 84 | expect(formatExpiresInLabel("2026-04-07T12:15:00.000Z")).toBe("Expires in 15m"); | |
| 85 | }); | |
| 86 | ||
| 87 | it("uses hours with minute and second remainder", () => { | |
| 88 | expect(formatExpiresInLabel("2026-04-07T14:02:03.000Z")).toBe("Expires in 2h 2m 3s"); | |
| 89 | expect(formatExpiresInLabel("2026-04-07T18:00:00.000Z")).toBe("Expires in 6h"); | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | describe("formatElapsedDurationLabel", () => { | |
| 94 | beforeEach(() => { | |
| 95 | vi.useFakeTimers(); | |
| 96 | vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); | |
| 97 | }); | |
| 98 | ||
| 99 | afterEach(() => { | |
| 100 | vi.useRealTimers(); | |
| 101 | }); | |
| 102 | ||
| 103 | it("returns just now when the instant is current or in the future", () => { | |
| 104 | expect(formatElapsedDurationLabel("2026-04-07T12:00:00.000Z")).toBe("just now"); | |
| 105 | expect(formatElapsedDurationLabel("2026-04-07T12:01:00.000Z")).toBe("just now"); | |
| 106 | }); | |
| 107 | ||
| 108 | it("formats seconds, minutes, hours, and days", () => { | |
| 109 | expect(formatElapsedDurationLabel("2026-04-07T11:59:45.000Z")).toBe("15s"); | |
| 110 | expect(formatElapsedDurationLabel("2026-04-07T11:45:00.000Z")).toBe("15m"); | |
| 111 | expect(formatElapsedDurationLabel("2026-04-07T06:00:00.000Z")).toBe("6h"); | |
| 112 | expect(formatElapsedDurationLabel("2026-04-03T12:00:00.000Z")).toBe("4d"); | |
| 113 | }); | |
| 114 | }); |
apps/web/src/timestampFormat.ts+87| ... | ... | @@ -71,3 +71,90 @@ export function formatRelativeTimeLabel(isoDate: string) { |
| 71 | 71 | const relative = formatRelativeTime(isoDate); |
| 72 | 72 | return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; |
| 73 | 73 | } |
| 74 | ||
| 75 | /** | |
| 76 | * Relative elapsed duration since an ISO instant, without an "ago" suffix. | |
| 77 | * Useful for labels like "Connected for 3m". | |
| 78 | */ | |
| 79 | export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string { | |
| 80 | const diffMs = nowMs - new Date(isoDate).getTime(); | |
| 81 | if (diffMs <= 0) return "just now"; | |
| 82 | ||
| 83 | const seconds = Math.floor(diffMs / 1000); | |
| 84 | if (seconds < 5) return "just now"; | |
| 85 | if (seconds < 60) return `${seconds}s`; | |
| 86 | ||
| 87 | const minutes = Math.floor(seconds / 60); | |
| 88 | if (minutes < 60) return `${minutes}m`; | |
| 89 | ||
| 90 | const hours = Math.floor(minutes / 60); | |
| 91 | if (hours < 24) return `${hours}h`; | |
| 92 | ||
| 93 | const days = Math.floor(hours / 24); | |
| 94 | return `${days}d`; | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times. | |
| 99 | */ | |
| 100 | export function formatRelativeTimeUntil(isoDate: string): { value: string; suffix: string | null } { | |
| 101 | const diffMs = new Date(isoDate).getTime() - Date.now(); | |
| 102 | if (diffMs <= 0) return { value: "Expired", suffix: null }; | |
| 103 | const seconds = Math.floor(diffMs / 1000); | |
| 104 | if (seconds < 5) return { value: "Soon", suffix: null }; | |
| 105 | if (seconds < 60) return { value: `${seconds}s`, suffix: "left" }; | |
| 106 | const minutes = Math.floor(seconds / 60); | |
| 107 | if (minutes < 60) return { value: `${minutes}m`, suffix: "left" }; | |
| 108 | const hours = Math.floor(minutes / 60); | |
| 109 | if (hours < 24) return { value: `${hours}h`, suffix: "left" }; | |
| 110 | const days = Math.floor(hours / 24); | |
| 111 | return { value: `${days}d`, suffix: "left" }; | |
| 112 | } | |
| 113 | ||
| 114 | export function formatRelativeTimeUntilLabel(isoDate: string): string { | |
| 115 | const relative = formatRelativeTimeUntil(isoDate); | |
| 116 | return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Countdown for a future instant (e.g. link expiry): "Expires in 4m 12s", with second precision under one hour. | |
| 121 | * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. | |
| 122 | */ | |
| 123 | export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string { | |
| 124 | const diffMs = new Date(isoDate).getTime() - nowMs; | |
| 125 | if (diffMs <= 0) return "Expired"; | |
| 126 | ||
| 127 | const totalSeconds = Math.floor(diffMs / 1000); | |
| 128 | if (totalSeconds < 5) return "Expires in a moment"; | |
| 129 | if (totalSeconds < 60) return `Expires in ${totalSeconds}s`; | |
| 130 | ||
| 131 | if (totalSeconds < 3600) { | |
| 132 | const minutes = Math.floor(totalSeconds / 60); | |
| 133 | const seconds = totalSeconds % 60; | |
| 134 | return seconds === 0 ? `Expires in ${minutes}m` : `Expires in ${minutes}m ${seconds}s`; | |
| 135 | } | |
| 136 | ||
| 137 | if (totalSeconds < 86_400) { | |
| 138 | const hours = Math.floor(totalSeconds / 3600); | |
| 139 | const rem = totalSeconds % 3600; | |
| 140 | const minutes = Math.floor(rem / 60); | |
| 141 | const seconds = rem % 60; | |
| 142 | const parts = [`${hours}h`]; | |
| 143 | if (minutes > 0) parts.push(`${minutes}m`); | |
| 144 | if (seconds > 0) parts.push(`${seconds}s`); | |
| 145 | return `Expires in ${parts.join(" ")}`; | |
| 146 | } | |
| 147 | ||
| 148 | const days = Math.floor(totalSeconds / 86_400); | |
| 149 | const remAfterDays = totalSeconds % 86_400; | |
| 150 | if (remAfterDays === 0) return `Expires in ${days}d`; | |
| 151 | const hours = Math.floor(remAfterDays / 3600); | |
| 152 | const rem = remAfterDays % 3600; | |
| 153 | const minutes = Math.floor(rem / 60); | |
| 154 | const seconds = rem % 60; | |
| 155 | const tail: string[] = []; | |
| 156 | if (hours > 0) tail.push(`${hours}h`); | |
| 157 | if (minutes > 0) tail.push(`${minutes}m`); | |
| 158 | if (seconds > 0) tail.push(`${seconds}s`); | |
| 159 | return tail.length > 0 ? `Expires in ${days}d ${tail.join(" ")}` : `Expires in ${days}d`; | |
| 160 | } |
apps/web/src/uiStateStore.ts+1-1| ... | ... | @@ -405,7 +405,7 @@ export const useUiStateStore = create<UiStateStore>((set) => ({ |
| 405 | 405 | |
| 406 | 406 | useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); |
| 407 | 407 | |
| 408 | if (typeof window !== "undefined") { | |
| 408 | if (typeof window !== "undefined" && typeof window.addEventListener === "function") { | |
| 409 | 409 | window.addEventListener("beforeunload", () => { |
| 410 | 410 | debouncedPersistState.flush(); |
| 411 | 411 | }); |
apps/web/src/wsRpcClient.test.ts deleted-130| ... | ... | @@ -1,130 +0,0 @@ |
| 1 | import type { | |
| 2 | GitStatusLocalResult, | |
| 3 | GitStatusRemoteResult, | |
| 4 | GitStatusStreamEvent, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 7 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 8 | ||
| 9 | vi.mock("./wsTransport", () => ({ | |
| 10 | WsTransport: class WsTransport { | |
| 11 | dispose = vi.fn(async () => undefined); | |
| 12 | reconnect = vi.fn(async () => undefined); | |
| 13 | request = vi.fn(); | |
| 14 | requestStream = vi.fn(); | |
| 15 | subscribe = vi.fn(() => () => undefined); | |
| 16 | }, | |
| 17 | })); | |
| 18 | ||
| 19 | import { | |
| 20 | __resetWsRpcClientForTests, | |
| 21 | createWsRpcClient, | |
| 22 | ensureWsRpcClientEntryForKnownEnvironment, | |
| 23 | readWsRpcClientEntryForEnvironment, | |
| 24 | } from "./wsRpcClient"; | |
| 25 | import { type WsTransport } from "./wsTransport"; | |
| 26 | ||
| 27 | const baseLocalStatus: GitStatusLocalResult = { | |
| 28 | isRepo: true, | |
| 29 | hasOriginRemote: true, | |
| 30 | isDefaultBranch: false, | |
| 31 | branch: "feature/demo", | |
| 32 | hasWorkingTreeChanges: false, | |
| 33 | workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 34 | }; | |
| 35 | ||
| 36 | const baseRemoteStatus: GitStatusRemoteResult = { | |
| 37 | hasUpstream: true, | |
| 38 | aheadCount: 0, | |
| 39 | behindCount: 0, | |
| 40 | pr: null, | |
| 41 | }; | |
| 42 | ||
| 43 | describe("wsRpcClient", () => { | |
| 44 | afterEach(async () => { | |
| 45 | await __resetWsRpcClientForTests(); | |
| 46 | }); | |
| 47 | ||
| 48 | it("reduces git status stream events into flat status snapshots", () => { | |
| 49 | const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => { | |
| 50 | for (const event of [ | |
| 51 | { | |
| 52 | _tag: "snapshot", | |
| 53 | local: baseLocalStatus, | |
| 54 | remote: null, | |
| 55 | }, | |
| 56 | { | |
| 57 | _tag: "remoteUpdated", | |
| 58 | remote: baseRemoteStatus, | |
| 59 | }, | |
| 60 | { | |
| 61 | _tag: "localUpdated", | |
| 62 | local: { | |
| 63 | ...baseLocalStatus, | |
| 64 | hasWorkingTreeChanges: true, | |
| 65 | }, | |
| 66 | }, | |
| 67 | ] satisfies GitStatusStreamEvent[]) { | |
| 68 | listener(event as TValue); | |
| 69 | } | |
| 70 | return () => undefined; | |
| 71 | }); | |
| 72 | ||
| 73 | const transport = { | |
| 74 | dispose: vi.fn(async () => undefined), | |
| 75 | reconnect: vi.fn(async () => undefined), | |
| 76 | request: vi.fn(), | |
| 77 | requestStream: vi.fn(), | |
| 78 | subscribe, | |
| 79 | } satisfies Pick< | |
| 80 | WsTransport, | |
| 81 | "dispose" | "reconnect" | "request" | "requestStream" | "subscribe" | |
| 82 | >; | |
| 83 | ||
| 84 | const client = createWsRpcClient(transport as unknown as WsTransport); | |
| 85 | const listener = vi.fn(); | |
| 86 | ||
| 87 | client.git.onStatus({ cwd: "/repo" }, listener); | |
| 88 | ||
| 89 | expect(listener.mock.calls).toEqual([ | |
| 90 | [ | |
| 91 | { | |
| 92 | ...baseLocalStatus, | |
| 93 | hasUpstream: false, | |
| 94 | aheadCount: 0, | |
| 95 | behindCount: 0, | |
| 96 | pr: null, | |
| 97 | }, | |
| 98 | ], | |
| 99 | [ | |
| 100 | { | |
| 101 | ...baseLocalStatus, | |
| 102 | ...baseRemoteStatus, | |
| 103 | }, | |
| 104 | ], | |
| 105 | [ | |
| 106 | { | |
| 107 | ...baseLocalStatus, | |
| 108 | ...baseRemoteStatus, | |
| 109 | hasWorkingTreeChanges: true, | |
| 110 | }, | |
| 111 | ], | |
| 112 | ]); | |
| 113 | }); | |
| 114 | ||
| 115 | it("does not fall back to the only registered client for an unbound environment", () => { | |
| 116 | ensureWsRpcClientEntryForKnownEnvironment({ | |
| 117 | id: "known-env-a", | |
| 118 | label: "Environment A", | |
| 119 | source: "manual", | |
| 120 | target: { | |
| 121 | type: "ws", | |
| 122 | wsUrl: "ws://localhost:3000", | |
| 123 | }, | |
| 124 | }); | |
| 125 | ||
| 126 | expect( | |
| 127 | readWsRpcClientEntryForEnvironment(EnvironmentId.makeUnsafe("environment-b")), | |
| 128 | ).toBeNull(); | |
| 129 | }); | |
| 130 | }); |
apps/web/src/wsRpcClient.ts deleted-391| ... | ... | @@ -1,391 +0,0 @@ |
| 1 | import { | |
| 2 | type GitActionProgressEvent, | |
| 3 | type GitRunStackedActionInput, | |
| 4 | type GitRunStackedActionResult, | |
| 5 | type GitStatusResult, | |
| 6 | type GitStatusStreamEvent, | |
| 7 | type LocalApi, | |
| 8 | ORCHESTRATION_WS_METHODS, | |
| 9 | type EnvironmentId, | |
| 10 | type ServerSettingsPatch, | |
| 11 | WS_METHODS, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import { getKnownEnvironmentBaseUrl, type KnownEnvironment } from "@t3tools/client-runtime"; | |
| 14 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; | |
| 15 | import { Effect, Stream } from "effect"; | |
| 16 | ||
| 17 | import { | |
| 18 | getPrimaryKnownEnvironment, | |
| 19 | resolvePrimaryEnvironmentBootstrapUrl, | |
| 20 | } from "./environmentBootstrap"; | |
| 21 | import { type WsRpcProtocolClient } from "./rpc/protocol"; | |
| 22 | import { resetWsReconnectBackoff } from "./rpc/wsConnectionState"; | |
| 23 | import { WsTransport } from "./wsTransport"; | |
| 24 | ||
| 25 | type RpcTag = keyof WsRpcProtocolClient & string; | |
| 26 | type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag]; | |
| 27 | type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0]; | |
| 28 | ||
| 29 | interface StreamSubscriptionOptions { | |
| 30 | readonly onResubscribe?: () => void; | |
| 31 | } | |
| 32 | ||
| 33 | type RpcUnaryMethod<TTag extends RpcTag> = | |
| 34 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | |
| 35 | ? (input: RpcInput<TTag>) => Promise<TSuccess> | |
| 36 | : never; | |
| 37 | ||
| 38 | type RpcUnaryNoArgMethod<TTag extends RpcTag> = | |
| 39 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | |
| 40 | ? () => Promise<TSuccess> | |
| 41 | : never; | |
| 42 | ||
| 43 | type RpcStreamMethod<TTag extends RpcTag> = | |
| 44 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any> | |
| 45 | ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void | |
| 46 | : never; | |
| 47 | ||
| 48 | interface GitRunStackedActionOptions { | |
| 49 | readonly onProgress?: (event: GitActionProgressEvent) => void; | |
| 50 | } | |
| 51 | ||
| 52 | export interface WsRpcClient { | |
| 53 | readonly dispose: () => Promise<void>; | |
| 54 | readonly reconnect: () => Promise<void>; | |
| 55 | readonly terminal: { | |
| 56 | readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>; | |
| 57 | readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>; | |
| 58 | readonly resize: RpcUnaryMethod<typeof WS_METHODS.terminalResize>; | |
| 59 | readonly clear: RpcUnaryMethod<typeof WS_METHODS.terminalClear>; | |
| 60 | readonly restart: RpcUnaryMethod<typeof WS_METHODS.terminalRestart>; | |
| 61 | readonly close: RpcUnaryMethod<typeof WS_METHODS.terminalClose>; | |
| 62 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>; | |
| 63 | }; | |
| 64 | readonly projects: { | |
| 65 | readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>; | |
| 66 | readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>; | |
| 67 | }; | |
| 68 | readonly shell: { | |
| 69 | readonly openInEditor: (input: { | |
| 70 | readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0]; | |
| 71 | readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1]; | |
| 72 | }) => ReturnType<LocalApi["shell"]["openInEditor"]>; | |
| 73 | }; | |
| 74 | readonly git: { | |
| 75 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; | |
| 76 | readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.gitRefreshStatus>; | |
| 77 | readonly onStatus: ( | |
| 78 | input: RpcInput<typeof WS_METHODS.subscribeGitStatus>, | |
| 79 | listener: (status: GitStatusResult) => void, | |
| 80 | options?: StreamSubscriptionOptions, | |
| 81 | ) => () => void; | |
| 82 | readonly runStackedAction: ( | |
| 83 | input: GitRunStackedActionInput, | |
| 84 | options?: GitRunStackedActionOptions, | |
| 85 | ) => Promise<GitRunStackedActionResult>; | |
| 86 | readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>; | |
| 87 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>; | |
| 88 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>; | |
| 89 | readonly createBranch: RpcUnaryMethod<typeof WS_METHODS.gitCreateBranch>; | |
| 90 | readonly checkout: RpcUnaryMethod<typeof WS_METHODS.gitCheckout>; | |
| 91 | readonly init: RpcUnaryMethod<typeof WS_METHODS.gitInit>; | |
| 92 | readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>; | |
| 93 | readonly preparePullRequestThread: RpcUnaryMethod< | |
| 94 | typeof WS_METHODS.gitPreparePullRequestThread | |
| 95 | >; | |
| 96 | }; | |
| 97 | readonly server: { | |
| 98 | readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>; | |
| 99 | readonly refreshProviders: RpcUnaryNoArgMethod<typeof WS_METHODS.serverRefreshProviders>; | |
| 100 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; | |
| 101 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; | |
| 102 | readonly updateSettings: ( | |
| 103 | patch: ServerSettingsPatch, | |
| 104 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>; | |
| 105 | readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>; | |
| 106 | readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>; | |
| 107 | }; | |
| 108 | readonly orchestration: { | |
| 109 | readonly getSnapshot: RpcUnaryNoArgMethod<typeof ORCHESTRATION_WS_METHODS.getSnapshot>; | |
| 110 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; | |
| 111 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; | |
| 112 | readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>; | |
| 113 | readonly replayEvents: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.replayEvents>; | |
| 114 | readonly onDomainEvent: RpcStreamMethod<typeof WS_METHODS.subscribeOrchestrationDomainEvents>; | |
| 115 | }; | |
| 116 | } | |
| 117 | ||
| 118 | export interface WsRpcClientEntry { | |
| 119 | readonly key: string; | |
| 120 | readonly knownEnvironment: KnownEnvironment; | |
| 121 | readonly client: WsRpcClient; | |
| 122 | readonly environmentId: EnvironmentId | null; | |
| 123 | } | |
| 124 | ||
| 125 | type MutableWsRpcClientEntry = { | |
| 126 | key: string; | |
| 127 | knownEnvironment: KnownEnvironment; | |
| 128 | client: WsRpcClient; | |
| 129 | environmentId: EnvironmentId | null; | |
| 130 | }; | |
| 131 | ||
| 132 | const wsRpcClientEntriesByKey = new Map<string, MutableWsRpcClientEntry>(); | |
| 133 | const wsRpcClientKeyByEnvironmentId = new Map<EnvironmentId, string>(); | |
| 134 | const wsRpcClientRegistryListeners = new Set<() => void>(); | |
| 135 | ||
| 136 | function emitWsRpcClientRegistryChange() { | |
| 137 | for (const listener of wsRpcClientRegistryListeners) { | |
| 138 | listener(); | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | function toReadonlyEntry(entry: MutableWsRpcClientEntry): WsRpcClientEntry { | |
| 143 | return entry; | |
| 144 | } | |
| 145 | ||
| 146 | function createWsRpcClientEntry(knownEnvironment: KnownEnvironment): MutableWsRpcClientEntry { | |
| 147 | const baseUrl = getKnownEnvironmentBaseUrl(knownEnvironment); | |
| 148 | if (!baseUrl) { | |
| 149 | throw new Error(`Unable to resolve websocket bootstrap URL for ${knownEnvironment.label}.`); | |
| 150 | } | |
| 151 | ||
| 152 | return { | |
| 153 | key: knownEnvironment.id, | |
| 154 | knownEnvironment, | |
| 155 | client: createWsRpcClient(new WsTransport(baseUrl)), | |
| 156 | environmentId: knownEnvironment.environmentId ?? null, | |
| 157 | }; | |
| 158 | } | |
| 159 | ||
| 160 | export function subscribeWsRpcClientRegistry(listener: () => void): () => void { | |
| 161 | wsRpcClientRegistryListeners.add(listener); | |
| 162 | return () => { | |
| 163 | wsRpcClientRegistryListeners.delete(listener); | |
| 164 | }; | |
| 165 | } | |
| 166 | ||
| 167 | export function listWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> { | |
| 168 | return [...wsRpcClientEntriesByKey.values()].map(toReadonlyEntry); | |
| 169 | } | |
| 170 | ||
| 171 | export function ensureWsRpcClientEntryForKnownEnvironment( | |
| 172 | knownEnvironment: KnownEnvironment, | |
| 173 | ): WsRpcClientEntry { | |
| 174 | const existingEntry = wsRpcClientEntriesByKey.get(knownEnvironment.id); | |
| 175 | if (existingEntry) { | |
| 176 | return toReadonlyEntry(existingEntry); | |
| 177 | } | |
| 178 | ||
| 179 | const entry = createWsRpcClientEntry(knownEnvironment); | |
| 180 | wsRpcClientEntriesByKey.set(entry.key, entry); | |
| 181 | if (entry.environmentId) { | |
| 182 | wsRpcClientKeyByEnvironmentId.set(entry.environmentId, entry.key); | |
| 183 | } | |
| 184 | emitWsRpcClientRegistryChange(); | |
| 185 | return toReadonlyEntry(entry); | |
| 186 | } | |
| 187 | ||
| 188 | export function getPrimaryWsRpcClientEntry(): WsRpcClientEntry { | |
| 189 | const primaryKnownEnvironment = getPrimaryKnownEnvironment(); | |
| 190 | if (!primaryKnownEnvironment) { | |
| 191 | throw new Error("Unable to resolve the primary websocket environment."); | |
| 192 | } | |
| 193 | ||
| 194 | return ensureWsRpcClientEntryForKnownEnvironment(primaryKnownEnvironment); | |
| 195 | } | |
| 196 | ||
| 197 | export function getWsRpcClient(): WsRpcClient { | |
| 198 | return getPrimaryWsRpcClientEntry().client; | |
| 199 | } | |
| 200 | ||
| 201 | export function bindWsRpcClientEntryEnvironment( | |
| 202 | clientKey: string, | |
| 203 | environmentId: EnvironmentId, | |
| 204 | ): void { | |
| 205 | const entry = wsRpcClientEntriesByKey.get(clientKey); | |
| 206 | if (!entry) { | |
| 207 | throw new Error(`No websocket client registered for key ${clientKey}.`); | |
| 208 | } | |
| 209 | ||
| 210 | const previousBoundEnvironmentId = entry.environmentId; | |
| 211 | const previousKeyForEnvironment = wsRpcClientKeyByEnvironmentId.get(environmentId); | |
| 212 | ||
| 213 | if (previousBoundEnvironmentId === environmentId && previousKeyForEnvironment === clientKey) { | |
| 214 | return; | |
| 215 | } | |
| 216 | ||
| 217 | if (previousBoundEnvironmentId) { | |
| 218 | wsRpcClientKeyByEnvironmentId.delete(previousBoundEnvironmentId); | |
| 219 | } | |
| 220 | ||
| 221 | if (previousKeyForEnvironment && previousKeyForEnvironment !== clientKey) { | |
| 222 | const previousEntry = wsRpcClientEntriesByKey.get(previousKeyForEnvironment); | |
| 223 | if (previousEntry) { | |
| 224 | previousEntry.environmentId = null; | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | entry.environmentId = environmentId; | |
| 229 | wsRpcClientKeyByEnvironmentId.set(environmentId, clientKey); | |
| 230 | emitWsRpcClientRegistryChange(); | |
| 231 | } | |
| 232 | ||
| 233 | export function bindPrimaryWsRpcClientEnvironment(environmentId: EnvironmentId): void { | |
| 234 | bindWsRpcClientEntryEnvironment(getPrimaryWsRpcClientEntry().key, environmentId); | |
| 235 | } | |
| 236 | ||
| 237 | export function readWsRpcClientEntryForEnvironment( | |
| 238 | environmentId: EnvironmentId, | |
| 239 | ): WsRpcClientEntry | null { | |
| 240 | const clientKey = wsRpcClientKeyByEnvironmentId.get(environmentId); | |
| 241 | if (clientKey) { | |
| 242 | const entry = wsRpcClientEntriesByKey.get(clientKey); | |
| 243 | return entry ? toReadonlyEntry(entry) : null; | |
| 244 | } | |
| 245 | ||
| 246 | return null; | |
| 247 | } | |
| 248 | ||
| 249 | export function getWsRpcClientForEnvironment(environmentId: EnvironmentId): WsRpcClient { | |
| 250 | const entry = readWsRpcClientEntryForEnvironment(environmentId); | |
| 251 | if (!entry) { | |
| 252 | throw new Error(`No websocket client registered for environment ${environmentId}.`); | |
| 253 | } | |
| 254 | return entry.client; | |
| 255 | } | |
| 256 | ||
| 257 | export async function __resetWsRpcClientForTests() { | |
| 258 | for (const entry of wsRpcClientEntriesByKey.values()) { | |
| 259 | await entry.client.dispose(); | |
| 260 | } | |
| 261 | wsRpcClientEntriesByKey.clear(); | |
| 262 | wsRpcClientKeyByEnvironmentId.clear(); | |
| 263 | wsRpcClientRegistryListeners.clear(); | |
| 264 | } | |
| 265 | ||
| 266 | export function createWsRpcClient( | |
| 267 | transport = new WsTransport(resolvePrimaryEnvironmentBootstrapUrl()), | |
| 268 | ): WsRpcClient { | |
| 269 | return { | |
| 270 | dispose: () => transport.dispose(), | |
| 271 | reconnect: async () => { | |
| 272 | resetWsReconnectBackoff(); | |
| 273 | await transport.reconnect(); | |
| 274 | }, | |
| 275 | terminal: { | |
| 276 | open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), | |
| 277 | write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), | |
| 278 | resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), | |
| 279 | clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), | |
| 280 | restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), | |
| 281 | close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), | |
| 282 | onEvent: (listener, options) => | |
| 283 | transport.subscribe( | |
| 284 | (client) => client[WS_METHODS.subscribeTerminalEvents]({}), | |
| 285 | listener, | |
| 286 | options, | |
| 287 | ), | |
| 288 | }, | |
| 289 | projects: { | |
| 290 | searchEntries: (input) => | |
| 291 | transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), | |
| 292 | writeFile: (input) => | |
| 293 | transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), | |
| 294 | }, | |
| 295 | shell: { | |
| 296 | openInEditor: (input) => | |
| 297 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), | |
| 298 | }, | |
| 299 | git: { | |
| 300 | pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), | |
| 301 | refreshStatus: (input) => | |
| 302 | transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), | |
| 303 | onStatus: (input, listener, options) => { | |
| 304 | let current: GitStatusResult | null = null; | |
| 305 | return transport.subscribe( | |
| 306 | (client) => client[WS_METHODS.subscribeGitStatus](input), | |
| 307 | (event: GitStatusStreamEvent) => { | |
| 308 | current = applyGitStatusStreamEvent(current, event); | |
| 309 | listener(current); | |
| 310 | }, | |
| 311 | options, | |
| 312 | ); | |
| 313 | }, | |
| 314 | runStackedAction: async (input, options) => { | |
| 315 | let result: GitRunStackedActionResult | null = null; | |
| 316 | ||
| 317 | await transport.requestStream( | |
| 318 | (client) => client[WS_METHODS.gitRunStackedAction](input), | |
| 319 | (event) => { | |
| 320 | options?.onProgress?.(event); | |
| 321 | if (event.kind === "action_finished") { | |
| 322 | result = event.result; | |
| 323 | } | |
| 324 | }, | |
| 325 | ); | |
| 326 | ||
| 327 | if (result) { | |
| 328 | return result; | |
| 329 | } | |
| 330 | ||
| 331 | throw new Error("Git action stream completed without a final result."); | |
| 332 | }, | |
| 333 | listBranches: (input) => | |
| 334 | transport.request((client) => client[WS_METHODS.gitListBranches](input)), | |
| 335 | createWorktree: (input) => | |
| 336 | transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)), | |
| 337 | removeWorktree: (input) => | |
| 338 | transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)), | |
| 339 | createBranch: (input) => | |
| 340 | transport.request((client) => client[WS_METHODS.gitCreateBranch](input)), | |
| 341 | checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)), | |
| 342 | init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)), | |
| 343 | resolvePullRequest: (input) => | |
| 344 | transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), | |
| 345 | preparePullRequestThread: (input) => | |
| 346 | transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), | |
| 347 | }, | |
| 348 | server: { | |
| 349 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), | |
| 350 | refreshProviders: () => | |
| 351 | transport.request((client) => client[WS_METHODS.serverRefreshProviders]({})), | |
| 352 | upsertKeybinding: (input) => | |
| 353 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), | |
| 354 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), | |
| 355 | updateSettings: (patch) => | |
| 356 | transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), | |
| 357 | subscribeConfig: (listener, options) => | |
| 358 | transport.subscribe( | |
| 359 | (client) => client[WS_METHODS.subscribeServerConfig]({}), | |
| 360 | listener, | |
| 361 | options, | |
| 362 | ), | |
| 363 | subscribeLifecycle: (listener, options) => | |
| 364 | transport.subscribe( | |
| 365 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | |
| 366 | listener, | |
| 367 | options, | |
| 368 | ), | |
| 369 | }, | |
| 370 | orchestration: { | |
| 371 | getSnapshot: () => | |
| 372 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getSnapshot]({})), | |
| 373 | dispatchCommand: (input) => | |
| 374 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), | |
| 375 | getTurnDiff: (input) => | |
| 376 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), | |
| 377 | getFullThreadDiff: (input) => | |
| 378 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), | |
| 379 | replayEvents: (input) => | |
| 380 | transport | |
| 381 | .request((client) => client[ORCHESTRATION_WS_METHODS.replayEvents](input)) | |
| 382 | .then((events) => [...events]), | |
| 383 | onDomainEvent: (listener, options) => | |
| 384 | transport.subscribe( | |
| 385 | (client) => client[WS_METHODS.subscribeOrchestrationDomainEvents]({}), | |
| 386 | listener, | |
| 387 | options, | |
| 388 | ), | |
| 389 | }, | |
| 390 | }; | |
| 391 | } |
apps/web/src/wsTransport.test.ts deleted-775| ... | ... | @@ -1,775 +0,0 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; | |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | ||
| 4 | import { | |
| 5 | __resetClientTracingForTests, | |
| 6 | configureClientTracing, | |
| 7 | } from "./observability/clientTracing"; | |
| 8 | import { | |
| 9 | getSlowRpcAckRequests, | |
| 10 | resetRequestLatencyStateForTests, | |
| 11 | setSlowRpcAckThresholdMsForTests, | |
| 12 | } from "./rpc/requestLatencyState"; | |
| 13 | import { | |
| 14 | getWsConnectionStatus, | |
| 15 | getWsConnectionUiState, | |
| 16 | resetWsConnectionStateForTests, | |
| 17 | } from "./rpc/wsConnectionState"; | |
| 18 | import { WsTransport } from "./wsTransport"; | |
| 19 | ||
| 20 | type WsEventType = "open" | "message" | "close" | "error"; | |
| 21 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; | |
| 22 | type WsListener = (event?: WsEvent) => void; | |
| 23 | ||
| 24 | const sockets: MockWebSocket[] = []; | |
| 25 | ||
| 26 | class MockWebSocket { | |
| 27 | static readonly CONNECTING = 0; | |
| 28 | static readonly OPEN = 1; | |
| 29 | static readonly CLOSING = 2; | |
| 30 | static readonly CLOSED = 3; | |
| 31 | ||
| 32 | readyState = MockWebSocket.CONNECTING; | |
| 33 | readonly sent: string[] = []; | |
| 34 | readonly url: string; | |
| 35 | private readonly listeners = new Map<WsEventType, Set<WsListener>>(); | |
| 36 | ||
| 37 | constructor(url: string) { | |
| 38 | this.url = url; | |
| 39 | sockets.push(this); | |
| 40 | } | |
| 41 | ||
| 42 | addEventListener(type: WsEventType, listener: WsListener) { | |
| 43 | const listeners = this.listeners.get(type) ?? new Set<WsListener>(); | |
| 44 | listeners.add(listener); | |
| 45 | this.listeners.set(type, listeners); | |
| 46 | } | |
| 47 | ||
| 48 | removeEventListener(type: WsEventType, listener: WsListener) { | |
| 49 | this.listeners.get(type)?.delete(listener); | |
| 50 | } | |
| 51 | ||
| 52 | send(data: string) { | |
| 53 | this.sent.push(data); | |
| 54 | } | |
| 55 | ||
| 56 | close(code = 1000, reason = "") { | |
| 57 | this.readyState = MockWebSocket.CLOSED; | |
| 58 | this.emit("close", { code, reason, type: "close" }); | |
| 59 | } | |
| 60 | ||
| 61 | open() { | |
| 62 | this.readyState = MockWebSocket.OPEN; | |
| 63 | this.emit("open", { type: "open" }); | |
| 64 | } | |
| 65 | ||
| 66 | serverMessage(data: unknown) { | |
| 67 | this.emit("message", { data, type: "message" }); | |
| 68 | } | |
| 69 | ||
| 70 | error() { | |
| 71 | this.emit("error", { type: "error" }); | |
| 72 | } | |
| 73 | ||
| 74 | private emit(type: WsEventType, event?: WsEvent) { | |
| 75 | const listeners = this.listeners.get(type); | |
| 76 | if (!listeners) return; | |
| 77 | for (const listener of listeners) { | |
| 78 | listener(event); | |
| 79 | } | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | const originalWebSocket = globalThis.WebSocket; | |
| 84 | const originalFetch = globalThis.fetch; | |
| 85 | ||
| 86 | function getSocket(): MockWebSocket { | |
| 87 | const socket = sockets.at(-1); | |
| 88 | if (!socket) { | |
| 89 | throw new Error("Expected a websocket instance"); | |
| 90 | } | |
| 91 | return socket; | |
| 92 | } | |
| 93 | ||
| 94 | async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { | |
| 95 | const startedAt = Date.now(); | |
| 96 | for (;;) { | |
| 97 | try { | |
| 98 | assertion(); | |
| 99 | return; | |
| 100 | } catch (error) { | |
| 101 | if (Date.now() - startedAt >= timeoutMs) { | |
| 102 | throw error; | |
| 103 | } | |
| 104 | await new Promise((resolve) => setTimeout(resolve, 10)); | |
| 105 | } | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | beforeEach(() => { | |
| 110 | vi.useRealTimers(); | |
| 111 | sockets.length = 0; | |
| 112 | resetRequestLatencyStateForTests(); | |
| 113 | resetWsConnectionStateForTests(); | |
| 114 | ||
| 115 | Object.defineProperty(globalThis, "window", { | |
| 116 | configurable: true, | |
| 117 | value: { | |
| 118 | location: { | |
| 119 | origin: "http://localhost:3020", | |
| 120 | hostname: "localhost", | |
| 121 | port: "3020", | |
| 122 | protocol: "http:", | |
| 123 | }, | |
| 124 | desktopBridge: undefined, | |
| 125 | }, | |
| 126 | }); | |
| 127 | Object.defineProperty(globalThis, "navigator", { | |
| 128 | configurable: true, | |
| 129 | value: { onLine: true }, | |
| 130 | }); | |
| 131 | ||
| 132 | globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; | |
| 133 | }); | |
| 134 | ||
| 135 | afterEach(async () => { | |
| 136 | globalThis.WebSocket = originalWebSocket; | |
| 137 | globalThis.fetch = originalFetch; | |
| 138 | resetRequestLatencyStateForTests(); | |
| 139 | resetWsConnectionStateForTests(); | |
| 140 | await __resetClientTracingForTests(); | |
| 141 | vi.restoreAllMocks(); | |
| 142 | }); | |
| 143 | ||
| 144 | describe("WsTransport", () => { | |
| 145 | it("normalizes root websocket urls to /ws and preserves query params", async () => { | |
| 146 | const transport = new WsTransport("ws://localhost:3020/?token=secret-token"); | |
| 147 | ||
| 148 | await waitFor(() => { | |
| 149 | expect(sockets).toHaveLength(1); | |
| 150 | }); | |
| 151 | ||
| 152 | expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); | |
| 153 | await transport.dispose(); | |
| 154 | }); | |
| 155 | ||
| 156 | it("uses wss when falling back to an https page origin", async () => { | |
| 157 | Object.assign(window.location, { | |
| 158 | origin: "https://app.example.com", | |
| 159 | hostname: "app.example.com", | |
| 160 | port: "", | |
| 161 | protocol: "https:", | |
| 162 | }); | |
| 163 | ||
| 164 | const transport = new WsTransport(); | |
| 165 | ||
| 166 | await waitFor(() => { | |
| 167 | expect(sockets).toHaveLength(1); | |
| 168 | }); | |
| 169 | ||
| 170 | expect(getSocket().url).toBe("wss://app.example.com/ws"); | |
| 171 | await transport.dispose(); | |
| 172 | }); | |
| 173 | ||
| 174 | it("tracks initial connection failures for the app error state", async () => { | |
| 175 | const transport = new WsTransport("ws://localhost:3020"); | |
| 176 | ||
| 177 | await waitFor(() => { | |
| 178 | expect(sockets).toHaveLength(1); | |
| 179 | }); | |
| 180 | ||
| 181 | const socket = getSocket(); | |
| 182 | expect(getWsConnectionStatus()).toMatchObject({ | |
| 183 | attemptCount: 1, | |
| 184 | phase: "connecting", | |
| 185 | socketUrl: "ws://localhost:3020/ws", | |
| 186 | }); | |
| 187 | ||
| 188 | socket.error(); | |
| 189 | socket.close(1006, "server unavailable"); | |
| 190 | ||
| 191 | await waitFor(() => { | |
| 192 | expect(getWsConnectionStatus()).toMatchObject({ | |
| 193 | closeCode: 1006, | |
| 194 | closeReason: "server unavailable", | |
| 195 | hasConnected: false, | |
| 196 | lastError: "Unable to connect to the T3 server WebSocket.", | |
| 197 | phase: "disconnected", | |
| 198 | }); | |
| 199 | }); | |
| 200 | expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("error"); | |
| 201 | ||
| 202 | await transport.dispose(); | |
| 203 | }); | |
| 204 | ||
| 205 | it("surfaces reconnecting state after a live socket disconnects", async () => { | |
| 206 | const transport = new WsTransport("ws://localhost:3020"); | |
| 207 | ||
| 208 | await waitFor(() => { | |
| 209 | expect(sockets).toHaveLength(1); | |
| 210 | }); | |
| 211 | ||
| 212 | const socket = getSocket(); | |
| 213 | socket.open(); | |
| 214 | ||
| 215 | await waitFor(() => { | |
| 216 | expect(getWsConnectionStatus()).toMatchObject({ | |
| 217 | hasConnected: true, | |
| 218 | phase: "connected", | |
| 219 | }); | |
| 220 | }); | |
| 221 | ||
| 222 | socket.close(1013, "try again later"); | |
| 223 | ||
| 224 | await waitFor(() => { | |
| 225 | expect(getWsConnectionStatus()).toMatchObject({ | |
| 226 | closeReason: "try again later", | |
| 227 | hasConnected: true, | |
| 228 | }); | |
| 229 | }); | |
| 230 | expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("reconnecting"); | |
| 231 | ||
| 232 | await transport.dispose(); | |
| 233 | }); | |
| 234 | ||
| 235 | it("reconnects the websocket session without disposing the transport", async () => { | |
| 236 | const transport = new WsTransport("ws://localhost:3020"); | |
| 237 | ||
| 238 | await waitFor(() => { | |
| 239 | expect(sockets).toHaveLength(1); | |
| 240 | }); | |
| 241 | ||
| 242 | const firstSocket = getSocket(); | |
| 243 | firstSocket.open(); | |
| 244 | ||
| 245 | await waitFor(() => { | |
| 246 | expect(getWsConnectionStatus()).toMatchObject({ | |
| 247 | hasConnected: true, | |
| 248 | phase: "connected", | |
| 249 | }); | |
| 250 | }); | |
| 251 | ||
| 252 | await transport.reconnect(); | |
| 253 | ||
| 254 | await waitFor(() => { | |
| 255 | expect(sockets).toHaveLength(2); | |
| 256 | }); | |
| 257 | ||
| 258 | const secondSocket = getSocket(); | |
| 259 | expect(secondSocket).not.toBe(firstSocket); | |
| 260 | expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); | |
| 261 | ||
| 262 | const requestPromise = transport.request((client) => | |
| 263 | client[WS_METHODS.serverUpsertKeybinding]({ | |
| 264 | command: "terminal.toggle", | |
| 265 | key: "ctrl+k", | |
| 266 | }), | |
| 267 | ); | |
| 268 | ||
| 269 | secondSocket.open(); | |
| 270 | ||
| 271 | await waitFor(() => { | |
| 272 | expect(secondSocket.sent).toHaveLength(1); | |
| 273 | }); | |
| 274 | ||
| 275 | const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string }; | |
| 276 | secondSocket.serverMessage( | |
| 277 | JSON.stringify({ | |
| 278 | _tag: "Exit", | |
| 279 | requestId: requestMessage.id, | |
| 280 | exit: { | |
| 281 | _tag: "Success", | |
| 282 | value: { | |
| 283 | keybindings: [], | |
| 284 | issues: [], | |
| 285 | }, | |
| 286 | }, | |
| 287 | }), | |
| 288 | ); | |
| 289 | ||
| 290 | await expect(requestPromise).resolves.toEqual({ | |
| 291 | keybindings: [], | |
| 292 | issues: [], | |
| 293 | }); | |
| 294 | ||
| 295 | await transport.dispose(); | |
| 296 | }); | |
| 297 | ||
| 298 | it("marks unary requests as slow until the first server ack arrives", async () => { | |
| 299 | const slowAckThresholdMs = 25; | |
| 300 | setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); | |
| 301 | const transport = new WsTransport("ws://localhost:3020"); | |
| 302 | ||
| 303 | const requestPromise = transport.request((client) => | |
| 304 | client[WS_METHODS.serverUpsertKeybinding]({ | |
| 305 | command: "terminal.toggle", | |
| 306 | key: "ctrl+k", | |
| 307 | }), | |
| 308 | ); | |
| 309 | ||
| 310 | await waitFor(() => { | |
| 311 | expect(sockets).toHaveLength(1); | |
| 312 | }); | |
| 313 | ||
| 314 | const socket = getSocket(); | |
| 315 | socket.open(); | |
| 316 | ||
| 317 | await waitFor(() => { | |
| 318 | expect(socket.sent).toHaveLength(1); | |
| 319 | }); | |
| 320 | ||
| 321 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 322 | await waitFor(() => { | |
| 323 | expect(getSlowRpcAckRequests()).toMatchObject([ | |
| 324 | { | |
| 325 | requestId: requestMessage.id, | |
| 326 | tag: WS_METHODS.serverUpsertKeybinding, | |
| 327 | }, | |
| 328 | ]); | |
| 329 | }, 1_000); | |
| 330 | ||
| 331 | socket.serverMessage( | |
| 332 | JSON.stringify({ | |
| 333 | _tag: "Exit", | |
| 334 | requestId: requestMessage.id, | |
| 335 | exit: { | |
| 336 | _tag: "Success", | |
| 337 | value: { | |
| 338 | keybindings: [], | |
| 339 | issues: [], | |
| 340 | }, | |
| 341 | }, | |
| 342 | }), | |
| 343 | ); | |
| 344 | ||
| 345 | await expect(requestPromise).resolves.toEqual({ | |
| 346 | keybindings: [], | |
| 347 | issues: [], | |
| 348 | }); | |
| 349 | expect(getSlowRpcAckRequests()).toEqual([]); | |
| 350 | ||
| 351 | await transport.dispose(); | |
| 352 | }, 5_000); | |
| 353 | ||
| 354 | it("sends unary RPC requests and resolves successful exits", async () => { | |
| 355 | const transport = new WsTransport("ws://localhost:3020"); | |
| 356 | ||
| 357 | const requestPromise = transport.request((client) => | |
| 358 | client[WS_METHODS.serverUpsertKeybinding]({ | |
| 359 | command: "terminal.toggle", | |
| 360 | key: "ctrl+k", | |
| 361 | }), | |
| 362 | ); | |
| 363 | ||
| 364 | await waitFor(() => { | |
| 365 | expect(sockets).toHaveLength(1); | |
| 366 | }); | |
| 367 | ||
| 368 | const socket = getSocket(); | |
| 369 | socket.open(); | |
| 370 | ||
| 371 | await waitFor(() => { | |
| 372 | expect(socket.sent).toHaveLength(1); | |
| 373 | }); | |
| 374 | ||
| 375 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | |
| 376 | _tag: string; | |
| 377 | id: string; | |
| 378 | payload: unknown; | |
| 379 | tag: string; | |
| 380 | }; | |
| 381 | expect(requestMessage).toMatchObject({ | |
| 382 | _tag: "Request", | |
| 383 | tag: WS_METHODS.serverUpsertKeybinding, | |
| 384 | payload: { | |
| 385 | command: "terminal.toggle", | |
| 386 | key: "ctrl+k", | |
| 387 | }, | |
| 388 | }); | |
| 389 | ||
| 390 | socket.serverMessage( | |
| 391 | JSON.stringify({ | |
| 392 | _tag: "Exit", | |
| 393 | requestId: requestMessage.id, | |
| 394 | exit: { | |
| 395 | _tag: "Success", | |
| 396 | value: { | |
| 397 | keybindings: [], | |
| 398 | issues: [], | |
| 399 | }, | |
| 400 | }, | |
| 401 | }), | |
| 402 | ); | |
| 403 | ||
| 404 | await expect(requestPromise).resolves.toEqual({ | |
| 405 | keybindings: [], | |
| 406 | issues: [], | |
| 407 | }); | |
| 408 | ||
| 409 | await transport.dispose(); | |
| 410 | }); | |
| 411 | ||
| 412 | it("delivers stream chunks to subscribers", async () => { | |
| 413 | const transport = new WsTransport("ws://localhost:3020"); | |
| 414 | const listener = vi.fn(); | |
| 415 | ||
| 416 | const unsubscribe = transport.subscribe( | |
| 417 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | |
| 418 | listener, | |
| 419 | ); | |
| 420 | await waitFor(() => { | |
| 421 | expect(sockets).toHaveLength(1); | |
| 422 | }); | |
| 423 | ||
| 424 | const socket = getSocket(); | |
| 425 | socket.open(); | |
| 426 | ||
| 427 | await waitFor(() => { | |
| 428 | expect(socket.sent).toHaveLength(1); | |
| 429 | }); | |
| 430 | ||
| 431 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | |
| 432 | expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); | |
| 433 | ||
| 434 | const welcomeEvent = { | |
| 435 | version: 1, | |
| 436 | sequence: 1, | |
| 437 | type: "welcome", | |
| 438 | payload: { | |
| 439 | environment: { | |
| 440 | environmentId: "environment-local", | |
| 441 | label: "Local environment", | |
| 442 | platform: { os: "darwin", arch: "arm64" }, | |
| 443 | serverVersion: "0.0.0-test", | |
| 444 | capabilities: { repositoryIdentity: true }, | |
| 445 | }, | |
| 446 | cwd: "/tmp/workspace", | |
| 447 | projectName: "workspace", | |
| 448 | }, | |
| 449 | }; | |
| 450 | ||
| 451 | socket.serverMessage( | |
| 452 | JSON.stringify({ | |
| 453 | _tag: "Chunk", | |
| 454 | requestId: requestMessage.id, | |
| 455 | values: [welcomeEvent], | |
| 456 | }), | |
| 457 | ); | |
| 458 | ||
| 459 | await waitFor(() => { | |
| 460 | expect(listener).toHaveBeenCalledWith(welcomeEvent); | |
| 461 | }); | |
| 462 | ||
| 463 | unsubscribe(); | |
| 464 | await transport.dispose(); | |
| 465 | }); | |
| 466 | ||
| 467 | it("re-subscribes stream listeners after the stream exits", async () => { | |
| 468 | const transport = new WsTransport("ws://localhost:3020"); | |
| 469 | const listener = vi.fn(); | |
| 470 | const onResubscribe = vi.fn(); | |
| 471 | ||
| 472 | const unsubscribe = transport.subscribe( | |
| 473 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | |
| 474 | listener, | |
| 475 | { onResubscribe }, | |
| 476 | ); | |
| 477 | await waitFor(() => { | |
| 478 | expect(sockets).toHaveLength(1); | |
| 479 | }); | |
| 480 | ||
| 481 | const socket = getSocket(); | |
| 482 | socket.open(); | |
| 483 | ||
| 484 | await waitFor(() => { | |
| 485 | expect(socket.sent).toHaveLength(1); | |
| 486 | }); | |
| 487 | ||
| 488 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 489 | socket.serverMessage( | |
| 490 | JSON.stringify({ | |
| 491 | _tag: "Chunk", | |
| 492 | requestId: firstRequest.id, | |
| 493 | values: [ | |
| 494 | { | |
| 495 | version: 1, | |
| 496 | sequence: 1, | |
| 497 | type: "welcome", | |
| 498 | payload: { | |
| 499 | environment: { | |
| 500 | environmentId: "environment-local", | |
| 501 | label: "Local environment", | |
| 502 | platform: { os: "darwin", arch: "arm64" }, | |
| 503 | serverVersion: "0.0.0-test", | |
| 504 | capabilities: { repositoryIdentity: true }, | |
| 505 | }, | |
| 506 | cwd: "/tmp/one", | |
| 507 | projectName: "one", | |
| 508 | }, | |
| 509 | }, | |
| 510 | ], | |
| 511 | }), | |
| 512 | ); | |
| 513 | socket.serverMessage( | |
| 514 | JSON.stringify({ | |
| 515 | _tag: "Exit", | |
| 516 | requestId: firstRequest.id, | |
| 517 | exit: { | |
| 518 | _tag: "Success", | |
| 519 | value: null, | |
| 520 | }, | |
| 521 | }), | |
| 522 | ); | |
| 523 | ||
| 524 | await waitFor(() => { | |
| 525 | const nextRequest = socket.sent | |
| 526 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | |
| 527 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | |
| 528 | expect(nextRequest).toBeDefined(); | |
| 529 | }); | |
| 530 | expect(onResubscribe).toHaveBeenCalledOnce(); | |
| 531 | ||
| 532 | const secondRequest = socket.sent | |
| 533 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) | |
| 534 | .find( | |
| 535 | (message): message is { _tag: "Request"; id: string; tag: string } => | |
| 536 | message._tag === "Request" && message.id !== firstRequest.id, | |
| 537 | ); | |
| 538 | if (!secondRequest) { | |
| 539 | throw new Error("Expected a resubscribe request"); | |
| 540 | } | |
| 541 | expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); | |
| 542 | expect(secondRequest.id).not.toBe(firstRequest.id); | |
| 543 | ||
| 544 | const secondEvent = { | |
| 545 | version: 1, | |
| 546 | sequence: 2, | |
| 547 | type: "welcome", | |
| 548 | payload: { | |
| 549 | environment: { | |
| 550 | environmentId: "environment-local", | |
| 551 | label: "Local environment", | |
| 552 | platform: { os: "darwin", arch: "arm64" }, | |
| 553 | serverVersion: "0.0.0-test", | |
| 554 | capabilities: { repositoryIdentity: true }, | |
| 555 | }, | |
| 556 | cwd: "/tmp/two", | |
| 557 | projectName: "two", | |
| 558 | }, | |
| 559 | }; | |
| 560 | socket.serverMessage( | |
| 561 | JSON.stringify({ | |
| 562 | _tag: "Chunk", | |
| 563 | requestId: secondRequest.id, | |
| 564 | values: [secondEvent], | |
| 565 | }), | |
| 566 | ); | |
| 567 | ||
| 568 | await waitFor(() => { | |
| 569 | expect(listener).toHaveBeenLastCalledWith(secondEvent); | |
| 570 | }); | |
| 571 | ||
| 572 | unsubscribe(); | |
| 573 | await transport.dispose(); | |
| 574 | }); | |
| 575 | ||
| 576 | it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { | |
| 577 | const transport = new WsTransport("ws://localhost:3020"); | |
| 578 | const listener = vi.fn(); | |
| 579 | const onResubscribe = vi.fn(); | |
| 580 | ||
| 581 | const unsubscribe = transport.subscribe( | |
| 582 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | |
| 583 | listener, | |
| 584 | { onResubscribe }, | |
| 585 | ); | |
| 586 | await waitFor(() => { | |
| 587 | expect(sockets).toHaveLength(1); | |
| 588 | }); | |
| 589 | ||
| 590 | const socket = getSocket(); | |
| 591 | socket.open(); | |
| 592 | ||
| 593 | await waitFor(() => { | |
| 594 | expect(socket.sent).toHaveLength(1); | |
| 595 | }); | |
| 596 | ||
| 597 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 598 | socket.serverMessage( | |
| 599 | JSON.stringify({ | |
| 600 | _tag: "Exit", | |
| 601 | requestId: firstRequest.id, | |
| 602 | exit: { | |
| 603 | _tag: "Success", | |
| 604 | value: null, | |
| 605 | }, | |
| 606 | }), | |
| 607 | ); | |
| 608 | ||
| 609 | await waitFor(() => { | |
| 610 | const nextRequest = socket.sent | |
| 611 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | |
| 612 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | |
| 613 | expect(nextRequest).toBeDefined(); | |
| 614 | }); | |
| 615 | expect(onResubscribe).not.toHaveBeenCalled(); | |
| 616 | expect(listener).not.toHaveBeenCalled(); | |
| 617 | ||
| 618 | unsubscribe(); | |
| 619 | await transport.dispose(); | |
| 620 | }); | |
| 621 | ||
| 622 | it("streams finite request events without re-subscribing", async () => { | |
| 623 | const transport = new WsTransport("ws://localhost:3020"); | |
| 624 | const listener = vi.fn(); | |
| 625 | ||
| 626 | await waitFor(() => { | |
| 627 | expect(sockets).toHaveLength(1); | |
| 628 | }); | |
| 629 | const socket = getSocket(); | |
| 630 | socket.open(); | |
| 631 | ||
| 632 | const requestPromise = transport.requestStream( | |
| 633 | (client) => | |
| 634 | client[WS_METHODS.gitRunStackedAction]({ | |
| 635 | actionId: "action-1", | |
| 636 | cwd: "/repo", | |
| 637 | action: "commit", | |
| 638 | }), | |
| 639 | listener, | |
| 640 | ); | |
| 641 | ||
| 642 | await waitFor(() => { | |
| 643 | expect(socket.sent).toHaveLength(1); | |
| 644 | }); | |
| 645 | ||
| 646 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | |
| 647 | const progressEvent = { | |
| 648 | actionId: "action-1", | |
| 649 | cwd: "/repo", | |
| 650 | action: "commit", | |
| 651 | kind: "phase_started", | |
| 652 | phase: "commit", | |
| 653 | label: "Committing...", | |
| 654 | } as const; | |
| 655 | ||
| 656 | socket.serverMessage( | |
| 657 | JSON.stringify({ | |
| 658 | _tag: "Chunk", | |
| 659 | requestId: requestMessage.id, | |
| 660 | values: [progressEvent], | |
| 661 | }), | |
| 662 | ); | |
| 663 | socket.serverMessage( | |
| 664 | JSON.stringify({ | |
| 665 | _tag: "Exit", | |
| 666 | requestId: requestMessage.id, | |
| 667 | exit: { | |
| 668 | _tag: "Success", | |
| 669 | value: null, | |
| 670 | }, | |
| 671 | }), | |
| 672 | ); | |
| 673 | ||
| 674 | await expect(requestPromise).resolves.toBeUndefined(); | |
| 675 | expect(listener).toHaveBeenCalledWith(progressEvent); | |
| 676 | expect( | |
| 677 | socket.sent.filter((message) => { | |
| 678 | const parsed = JSON.parse(message) as { _tag?: string; tag?: string }; | |
| 679 | return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction; | |
| 680 | }), | |
| 681 | ).toHaveLength(1); | |
| 682 | await transport.dispose(); | |
| 683 | }); | |
| 684 | ||
| 685 | it("closes the client scope on the transport runtime before disposing the runtime", async () => { | |
| 686 | const callOrder: string[] = []; | |
| 687 | let resolveClose!: () => void; | |
| 688 | const closePromise = new Promise<void>((resolve) => { | |
| 689 | resolveClose = resolve; | |
| 690 | }); | |
| 691 | ||
| 692 | const runtime = { | |
| 693 | runPromise: vi.fn(async () => { | |
| 694 | callOrder.push("close:start"); | |
| 695 | await closePromise; | |
| 696 | callOrder.push("close:done"); | |
| 697 | return undefined; | |
| 698 | }), | |
| 699 | dispose: vi.fn(async () => { | |
| 700 | callOrder.push("runtime:dispose"); | |
| 701 | }), | |
| 702 | }; | |
| 703 | const transport = { | |
| 704 | disposed: false, | |
| 705 | session: { | |
| 706 | clientScope: {} as never, | |
| 707 | runtime, | |
| 708 | }, | |
| 709 | closeSession: ( | |
| 710 | WsTransport.prototype as unknown as { | |
| 711 | closeSession: (session: { | |
| 712 | clientScope: unknown; | |
| 713 | runtime: { dispose: () => Promise<void>; runPromise: () => Promise<void> }; | |
| 714 | }) => Promise<void>; | |
| 715 | } | |
| 716 | ).closeSession, | |
| 717 | } as unknown as WsTransport; | |
| 718 | ||
| 719 | void WsTransport.prototype.dispose.call(transport); | |
| 720 | ||
| 721 | expect(runtime.runPromise).toHaveBeenCalledTimes(1); | |
| 722 | expect(runtime.dispose).not.toHaveBeenCalled(); | |
| 723 | expect((transport as unknown as { disposed: boolean }).disposed).toBe(true); | |
| 724 | ||
| 725 | resolveClose(); | |
| 726 | ||
| 727 | await waitFor(() => { | |
| 728 | expect(runtime.dispose).toHaveBeenCalledTimes(1); | |
| 729 | }); | |
| 730 | ||
| 731 | expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); | |
| 732 | }); | |
| 733 | ||
| 734 | it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => { | |
| 735 | await configureClientTracing({ | |
| 736 | exportIntervalMs: 10, | |
| 737 | }); | |
| 738 | ||
| 739 | const transport = new WsTransport("ws://localhost:3020"); | |
| 740 | const requestPromise = transport.request((client) => client[WS_METHODS.serverGetSettings]({})); | |
| 741 | ||
| 742 | await waitFor(() => { | |
| 743 | expect(sockets).toHaveLength(1); | |
| 744 | }); | |
| 745 | ||
| 746 | const socket = getSocket(); | |
| 747 | socket.open(); | |
| 748 | ||
| 749 | await waitFor(() => { | |
| 750 | expect(socket.sent).toHaveLength(1); | |
| 751 | }); | |
| 752 | ||
| 753 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | |
| 754 | id: string; | |
| 755 | spanId?: string; | |
| 756 | traceId?: string; | |
| 757 | }; | |
| 758 | expect(requestMessage.traceId).toMatch(/^[0-9a-f]{32}$/); | |
| 759 | expect(requestMessage.spanId).toMatch(/^[0-9a-f]{16}$/); | |
| 760 | ||
| 761 | socket.serverMessage( | |
| 762 | JSON.stringify({ | |
| 763 | _tag: "Exit", | |
| 764 | requestId: requestMessage.id, | |
| 765 | exit: { | |
| 766 | _tag: "Success", | |
| 767 | value: DEFAULT_SERVER_SETTINGS, | |
| 768 | }, | |
| 769 | }), | |
| 770 | ); | |
| 771 | ||
| 772 | await expect(requestPromise).resolves.toEqual(DEFAULT_SERVER_SETTINGS); | |
| 773 | await transport.dispose(); | |
| 774 | }); | |
| 775 | }); |
apps/web/src/wsTransport.ts deleted-265| ... | ... | @@ -1,265 +0,0 @@ |
| 1 | import { | |
| 2 | Cause, | |
| 3 | Duration, | |
| 4 | Effect, | |
| 5 | Exit, | |
| 6 | Layer, | |
| 7 | ManagedRuntime, | |
| 8 | Option, | |
| 9 | Scope, | |
| 10 | Stream, | |
| 11 | } from "effect"; | |
| 12 | import { RpcClient } from "effect/unstable/rpc"; | |
| 13 | ||
| 14 | import { ClientTracingLive, configureClientTracing } from "./observability/clientTracing"; | |
| 15 | import { | |
| 16 | createWsRpcProtocolLayer, | |
| 17 | makeWsRpcProtocolClient, | |
| 18 | type WsRpcProtocolClient, | |
| 19 | } from "./rpc/protocol"; | |
| 20 | ||
| 21 | interface SubscribeOptions { | |
| 22 | readonly retryDelay?: Duration.Input; | |
| 23 | readonly onResubscribe?: () => void; | |
| 24 | } | |
| 25 | ||
| 26 | interface RequestOptions { | |
| 27 | readonly timeout?: Option.Option<Duration.Input>; | |
| 28 | } | |
| 29 | ||
| 30 | const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250); | |
| 31 | const NOOP: () => void = () => undefined; | |
| 32 | ||
| 33 | interface TransportSession { | |
| 34 | readonly clientPromise: Promise<WsRpcProtocolClient>; | |
| 35 | readonly clientScope: Scope.Closeable; | |
| 36 | readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>; | |
| 37 | } | |
| 38 | ||
| 39 | function formatErrorMessage(error: unknown): string { | |
| 40 | if (error instanceof Error && error.message.trim().length > 0) { | |
| 41 | return error.message; | |
| 42 | } | |
| 43 | return String(error); | |
| 44 | } | |
| 45 | ||
| 46 | export class WsTransport { | |
| 47 | private readonly tracingReady: Promise<void>; | |
| 48 | private readonly url: string | undefined; | |
| 49 | private disposed = false; | |
| 50 | private reconnectChain: Promise<void> = Promise.resolve(); | |
| 51 | private session: TransportSession; | |
| 52 | ||
| 53 | constructor(url?: string) { | |
| 54 | this.url = url; | |
| 55 | this.tracingReady = configureClientTracing(); | |
| 56 | this.session = this.createSession(); | |
| 57 | } | |
| 58 | ||
| 59 | async request<TSuccess>( | |
| 60 | execute: (client: WsRpcProtocolClient) => Effect.Effect<TSuccess, Error, never>, | |
| 61 | _options?: RequestOptions, | |
| 62 | ): Promise<TSuccess> { | |
| 63 | if (this.disposed) { | |
| 64 | throw new Error("Transport disposed"); | |
| 65 | } | |
| 66 | ||
| 67 | await this.tracingReady; | |
| 68 | const session = this.session; | |
| 69 | const client = await session.clientPromise; | |
| 70 | return await session.runtime.runPromise(Effect.suspend(() => execute(client))); | |
| 71 | } | |
| 72 | ||
| 73 | async requestStream<TValue>( | |
| 74 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | |
| 75 | listener: (value: TValue) => void, | |
| 76 | ): Promise<void> { | |
| 77 | if (this.disposed) { | |
| 78 | throw new Error("Transport disposed"); | |
| 79 | } | |
| 80 | ||
| 81 | await this.tracingReady; | |
| 82 | const session = this.session; | |
| 83 | const client = await session.clientPromise; | |
| 84 | await session.runtime.runPromise( | |
| 85 | Stream.runForEach(connect(client), (value) => | |
| 86 | Effect.sync(() => { | |
| 87 | try { | |
| 88 | listener(value); | |
| 89 | } catch { | |
| 90 | // Swallow listener errors so the stream can finish cleanly. | |
| 91 | } | |
| 92 | }), | |
| 93 | ), | |
| 94 | ); | |
| 95 | } | |
| 96 | ||
| 97 | subscribe<TValue>( | |
| 98 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | |
| 99 | listener: (value: TValue) => void, | |
| 100 | options?: SubscribeOptions, | |
| 101 | ): () => void { | |
| 102 | if (this.disposed) { | |
| 103 | return () => undefined; | |
| 104 | } | |
| 105 | ||
| 106 | let active = true; | |
| 107 | let hasReceivedValue = false; | |
| 108 | const retryDelayMs = Duration.toMillis( | |
| 109 | Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS), | |
| 110 | ); | |
| 111 | let cancelCurrentStream: () => void = NOOP; | |
| 112 | ||
| 113 | void (async () => { | |
| 114 | for (;;) { | |
| 115 | if (!active || this.disposed) { | |
| 116 | return; | |
| 117 | } | |
| 118 | ||
| 119 | try { | |
| 120 | if (hasReceivedValue) { | |
| 121 | try { | |
| 122 | options?.onResubscribe?.(); | |
| 123 | } catch { | |
| 124 | // Swallow reconnect hook errors so the stream can recover. | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | const session = this.session; | |
| 129 | const runningStream = this.runStreamOnSession( | |
| 130 | session, | |
| 131 | connect, | |
| 132 | listener, | |
| 133 | () => active, | |
| 134 | () => { | |
| 135 | hasReceivedValue = true; | |
| 136 | }, | |
| 137 | ); | |
| 138 | cancelCurrentStream = runningStream.cancel; | |
| 139 | await runningStream.completed; | |
| 140 | cancelCurrentStream = NOOP; | |
| 141 | } catch (error) { | |
| 142 | cancelCurrentStream = NOOP; | |
| 143 | if (!active || this.disposed) { | |
| 144 | return; | |
| 145 | } | |
| 146 | ||
| 147 | console.warn("WebSocket RPC subscription disconnected", { | |
| 148 | error: formatErrorMessage(error), | |
| 149 | }); | |
| 150 | await sleep(retryDelayMs); | |
| 151 | } | |
| 152 | } | |
| 153 | })(); | |
| 154 | ||
| 155 | return () => { | |
| 156 | active = false; | |
| 157 | cancelCurrentStream(); | |
| 158 | }; | |
| 159 | } | |
| 160 | ||
| 161 | async reconnect() { | |
| 162 | if (this.disposed) { | |
| 163 | throw new Error("Transport disposed"); | |
| 164 | } | |
| 165 | ||
| 166 | const reconnectOperation = this.reconnectChain.then(async () => { | |
| 167 | if (this.disposed) { | |
| 168 | throw new Error("Transport disposed"); | |
| 169 | } | |
| 170 | ||
| 171 | const previousSession = this.session; | |
| 172 | this.session = this.createSession(); | |
| 173 | await this.closeSession(previousSession); | |
| 174 | }); | |
| 175 | ||
| 176 | this.reconnectChain = reconnectOperation.catch(() => undefined); | |
| 177 | await reconnectOperation; | |
| 178 | } | |
| 179 | ||
| 180 | async dispose() { | |
| 181 | if (this.disposed) { | |
| 182 | return; | |
| 183 | } | |
| 184 | this.disposed = true; | |
| 185 | await this.closeSession(this.session); | |
| 186 | } | |
| 187 | ||
| 188 | private closeSession(session: TransportSession) { | |
| 189 | return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { | |
| 190 | session.runtime.dispose(); | |
| 191 | }); | |
| 192 | } | |
| 193 | ||
| 194 | private createSession(): TransportSession { | |
| 195 | const runtime = ManagedRuntime.make( | |
| 196 | Layer.mergeAll(createWsRpcProtocolLayer(this.url), ClientTracingLive), | |
| 197 | ); | |
| 198 | const clientScope = runtime.runSync(Scope.make()); | |
| 199 | return { | |
| 200 | runtime, | |
| 201 | clientScope, | |
| 202 | clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)), | |
| 203 | }; | |
| 204 | } | |
| 205 | ||
| 206 | private runStreamOnSession<TValue>( | |
| 207 | session: TransportSession, | |
| 208 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | |
| 209 | listener: (value: TValue) => void, | |
| 210 | isActive: () => boolean, | |
| 211 | markValueReceived: () => void, | |
| 212 | ): { | |
| 213 | readonly cancel: () => void; | |
| 214 | readonly completed: Promise<void>; | |
| 215 | } { | |
| 216 | let resolveCompleted!: () => void; | |
| 217 | let rejectCompleted!: (error: unknown) => void; | |
| 218 | const completed = new Promise<void>((resolve, reject) => { | |
| 219 | resolveCompleted = resolve; | |
| 220 | rejectCompleted = reject; | |
| 221 | }); | |
| 222 | const cancel = session.runtime.runCallback( | |
| 223 | Effect.promise(() => this.tracingReady).pipe( | |
| 224 | Effect.flatMap(() => Effect.promise(() => session.clientPromise)), | |
| 225 | Effect.flatMap((client) => | |
| 226 | Stream.runForEach(connect(client), (value) => | |
| 227 | Effect.sync(() => { | |
| 228 | if (!isActive()) { | |
| 229 | return; | |
| 230 | } | |
| 231 | ||
| 232 | markValueReceived(); | |
| 233 | try { | |
| 234 | listener(value); | |
| 235 | } catch { | |
| 236 | // Swallow listener errors so the stream stays live. | |
| 237 | } | |
| 238 | }), | |
| 239 | ), | |
| 240 | ), | |
| 241 | ), | |
| 242 | { | |
| 243 | onExit: (exit) => { | |
| 244 | if (Exit.isSuccess(exit)) { | |
| 245 | resolveCompleted(); | |
| 246 | return; | |
| 247 | } | |
| 248 | ||
| 249 | rejectCompleted(Cause.squash(exit.cause)); | |
| 250 | }, | |
| 251 | }, | |
| 252 | ); | |
| 253 | ||
| 254 | return { | |
| 255 | cancel, | |
| 256 | completed, | |
| 257 | }; | |
| 258 | } | |
| 259 | } | |
| 260 | ||
| 261 | function sleep(ms: number): Promise<void> { | |
| 262 | return new Promise((resolve) => { | |
| 263 | setTimeout(resolve, ms); | |
| 264 | }); | |
| 265 | } |
apps/web/test/authHttpHandlers.ts created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | type ExecutionEnvironmentDescriptor, | |
| 4 | type ServerAuthDescriptor, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { HttpResponse, http } from "msw"; | |
| 7 | ||
| 8 | const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z"; | |
| 9 | const TEST_ENVIRONMENT_DESCRIPTOR: ExecutionEnvironmentDescriptor = { | |
| 10 | environmentId: EnvironmentId.makeUnsafe("environment-local"), | |
| 11 | label: "Local environment", | |
| 12 | platform: { | |
| 13 | os: "darwin", | |
| 14 | arch: "arm64", | |
| 15 | }, | |
| 16 | serverVersion: "0.0.0-test", | |
| 17 | capabilities: { | |
| 18 | repositoryIdentity: true, | |
| 19 | }, | |
| 20 | }; | |
| 21 | ||
| 22 | export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) { | |
| 23 | return [ | |
| 24 | http.get("*/.well-known/t3/environment", () => HttpResponse.json(TEST_ENVIRONMENT_DESCRIPTOR)), | |
| 25 | http.get("*/api/auth/session", () => | |
| 26 | HttpResponse.json({ | |
| 27 | authenticated: true, | |
| 28 | auth: getAuthDescriptor(), | |
| 29 | sessionMethod: "browser-session-cookie", | |
| 30 | expiresAt: TEST_SESSION_EXPIRES_AT, | |
| 31 | }), | |
| 32 | ), | |
| 33 | http.post("*/api/auth/bootstrap", () => | |
| 34 | HttpResponse.json({ | |
| 35 | authenticated: true, | |
| 36 | sessionMethod: "browser-session-cookie", | |
| 37 | expiresAt: TEST_SESSION_EXPIRES_AT, | |
| 38 | }), | |
| 39 | ), | |
| 40 | ] as const; | |
| 41 | } |
apps/web/test/wsRpcHarness.ts+8-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { Effect, Exit, PubSub, Scope, Stream } from "effect"; |
| 2 | 2 | import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; |
| 3 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; | |
| 3 | import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"; | |
| 4 | 4 | |
| 5 | 5 | type RpcServerInstance = RpcServer.RpcServer<any>; |
| 6 | 6 | |
| ... | ... | @@ -113,6 +113,13 @@ export class BrowserWsRpcHarness { |
| 113 | 113 | } |
| 114 | 114 | const messages = this.parser.decode(rawData); |
| 115 | 115 | for (const message of messages) { |
| 116 | if (message && typeof message === "object" && "_tag" in message && message._tag === "Ping") { | |
| 117 | const encoded = this.parser.encode(RpcMessage.constPong); | |
| 118 | if (typeof encoded === "string") { | |
| 119 | this.client?.send(encoded); | |
| 120 | } | |
| 121 | continue; | |
| 122 | } | |
| 116 | 123 | await Effect.runPromise(server.write(0, message as never)); |
| 117 | 124 | } |
| 118 | 125 | } |
apps/web/vite.config.ts+42-2| ... | ... | @@ -6,6 +6,8 @@ import { defineConfig } from "vite"; |
| 6 | 6 | import pkg from "./package.json" with { type: "json" }; |
| 7 | 7 | |
| 8 | 8 | const port = Number(process.env.PORT ?? 5733); |
| 9 | const host = process.env.HOST?.trim() || "localhost"; | |
| 10 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); | |
| 9 | 11 | const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); |
| 10 | 12 | |
| 11 | 13 | const buildSourcemap = |
| ... | ... | @@ -15,6 +17,29 @@ const buildSourcemap = |
| 15 | 17 | ? "hidden" |
| 16 | 18 | : true; |
| 17 | 19 | |
| 20 | function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { | |
| 21 | if (!wsUrl) { | |
| 22 | return undefined; | |
| 23 | } | |
| 24 | ||
| 25 | try { | |
| 26 | const url = new URL(wsUrl); | |
| 27 | if (url.protocol === "ws:") { | |
| 28 | url.protocol = "http:"; | |
| 29 | } else if (url.protocol === "wss:") { | |
| 30 | url.protocol = "https:"; | |
| 31 | } | |
| 32 | url.pathname = ""; | |
| 33 | url.search = ""; | |
| 34 | url.hash = ""; | |
| 35 | return url.toString(); | |
| 36 | } catch { | |
| 37 | return undefined; | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); | |
| 42 | ||
| 18 | 43 | export default defineConfig({ |
| 19 | 44 | plugins: [ |
| 20 | 45 | tanstackRouter(), |
| ... | ... | @@ -34,21 +59,36 @@ export default defineConfig({ |
| 34 | 59 | }, |
| 35 | 60 | define: { |
| 36 | 61 | // In dev mode, tell the web app where the WebSocket server lives |
| 37 | "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""), | |
| 62 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), | |
| 38 | 63 | "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), |
| 39 | 64 | }, |
| 40 | 65 | resolve: { |
| 41 | 66 | tsconfigPaths: true, |
| 42 | 67 | }, |
| 43 | 68 | server: { |
| 69 | host, | |
| 44 | 70 | port, |
| 45 | 71 | strictPort: true, |
| 72 | ...(devProxyTarget | |
| 73 | ? { | |
| 74 | proxy: { | |
| 75 | "/api": { | |
| 76 | target: devProxyTarget, | |
| 77 | changeOrigin: true, | |
| 78 | }, | |
| 79 | "/attachments": { | |
| 80 | target: devProxyTarget, | |
| 81 | changeOrigin: true, | |
| 82 | }, | |
| 83 | }, | |
| 84 | } | |
| 85 | : {}), | |
| 46 | 86 | hmr: { |
| 47 | 87 | // Explicit config so Vite's HMR WebSocket connects reliably |
| 48 | 88 | // inside Electron's BrowserWindow. Vite 8 uses console.debug for |
| 49 | 89 | // connection logs — enable "Verbose" in DevTools to see them. |
| 50 | 90 | protocol: "ws", |
| 51 | host: "localhost", | |
| 91 | host, | |
| 52 | 92 | }, |
| 53 | 93 | }, |
| 54 | 94 | build: { |
packages/client-runtime/src/knownEnvironment.test.ts+35-6| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | import { createKnownEnvironmentFromWsUrl } from "./knownEnvironment"; | |
| 4 | import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment"; | |
| 5 | 5 | import { |
| 6 | 6 | parseScopedProjectKey, |
| 7 | 7 | parseScopedThreadKey, |
| ... | ... | @@ -13,22 +13,51 @@ import { |
| 13 | 13 | } from "./scoped"; |
| 14 | 14 | |
| 15 | 15 | describe("known environment bootstrap helpers", () => { |
| 16 | it("creates known environments from explicit ws urls", () => { | |
| 16 | it("creates known environments from explicit server base urls", () => { | |
| 17 | 17 | expect( |
| 18 | createKnownEnvironmentFromWsUrl({ | |
| 18 | createKnownEnvironment({ | |
| 19 | 19 | label: "Remote environment", |
| 20 | wsUrl: "wss://remote.example.com/ws", | |
| 20 | target: { | |
| 21 | httpBaseUrl: "https://remote.example.com", | |
| 22 | wsBaseUrl: "wss://remote.example.com", | |
| 23 | }, | |
| 21 | 24 | }), |
| 22 | 25 | ).toEqual({ |
| 23 | 26 | id: "ws:Remote environment", |
| 24 | 27 | label: "Remote environment", |
| 25 | 28 | source: "manual", |
| 26 | 29 | target: { |
| 27 | type: "ws", | |
| 28 | wsUrl: "wss://remote.example.com/ws", | |
| 30 | httpBaseUrl: "https://remote.example.com", | |
| 31 | wsBaseUrl: "wss://remote.example.com", | |
| 29 | 32 | }, |
| 30 | 33 | }); |
| 31 | 34 | }); |
| 35 | ||
| 36 | it("returns the explicit fetchable http origin", () => { | |
| 37 | expect( | |
| 38 | getKnownEnvironmentHttpBaseUrl( | |
| 39 | createKnownEnvironment({ | |
| 40 | label: "Local environment", | |
| 41 | target: { | |
| 42 | httpBaseUrl: "http://localhost:3773", | |
| 43 | wsBaseUrl: "ws://localhost:3773", | |
| 44 | }, | |
| 45 | }), | |
| 46 | ), | |
| 47 | ).toBe("http://localhost:3773"); | |
| 48 | ||
| 49 | expect( | |
| 50 | getKnownEnvironmentHttpBaseUrl( | |
| 51 | createKnownEnvironment({ | |
| 52 | label: "Remote environment", | |
| 53 | target: { | |
| 54 | httpBaseUrl: "https://remote.example.com/api", | |
| 55 | wsBaseUrl: "wss://remote.example.com/api", | |
| 56 | }, | |
| 57 | }), | |
| 58 | ), | |
| 59 | ).toBe("https://remote.example.com/api"); | |
| 60 | }); | |
| 32 | 61 | }); |
| 33 | 62 | |
| 34 | 63 | describe("scoped refs", () => { |
packages/client-runtime/src/knownEnvironment.ts+25-11| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | export interface KnownEnvironmentConnectionTarget { |
| 4 | readonly type: "ws"; | |
| 5 | readonly wsUrl: string; | |
| 4 | readonly httpBaseUrl: string; | |
| 5 | readonly wsBaseUrl: string; | |
| 6 | 6 | } |
| 7 | 7 | |
| 8 | 8 | export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin"; |
| ... | ... | @@ -15,25 +15,39 @@ export interface KnownEnvironment { |
| 15 | 15 | readonly target: KnownEnvironmentConnectionTarget; |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | export function createKnownEnvironmentFromWsUrl(input: { | |
| 18 | export function createKnownEnvironment(input: { | |
| 19 | 19 | readonly id?: string; |
| 20 | 20 | readonly label: string; |
| 21 | 21 | readonly source?: KnownEnvironmentSource; |
| 22 | readonly wsUrl: string; | |
| 22 | readonly target: KnownEnvironmentConnectionTarget; | |
| 23 | 23 | }): KnownEnvironment { |
| 24 | 24 | return { |
| 25 | 25 | id: input.id ?? `ws:${input.label}`, |
| 26 | 26 | label: input.label, |
| 27 | 27 | source: input.source ?? "manual", |
| 28 | target: { | |
| 29 | type: "ws", | |
| 30 | wsUrl: input.wsUrl, | |
| 31 | }, | |
| 28 | target: input.target, | |
| 32 | 29 | }; |
| 33 | 30 | } |
| 34 | 31 | |
| 35 | export function getKnownEnvironmentBaseUrl( | |
| 32 | export function getKnownEnvironmentWsBaseUrl( | |
| 33 | environment: KnownEnvironment | null | undefined, | |
| 34 | ): string | null { | |
| 35 | return environment?.target.wsBaseUrl ?? null; | |
| 36 | } | |
| 37 | ||
| 38 | export function getKnownEnvironmentHttpBaseUrl( | |
| 36 | 39 | environment: KnownEnvironment | null | undefined, |
| 37 | 40 | ): string | null { |
| 38 | return environment?.target.wsUrl ?? null; | |
| 41 | return environment?.target.httpBaseUrl ?? null; | |
| 42 | } | |
| 43 | ||
| 44 | export function attachEnvironmentDescriptor( | |
| 45 | environment: KnownEnvironment, | |
| 46 | descriptor: ExecutionEnvironmentDescriptor, | |
| 47 | ): KnownEnvironment { | |
| 48 | return { | |
| 49 | ...environment, | |
| 50 | environmentId: descriptor.environmentId, | |
| 51 | label: descriptor.label, | |
| 52 | }; | |
| 39 | 53 | } |
packages/contracts/src/auth.ts created+266| ... | ... | @@ -0,0 +1,266 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas"; | |
| 4 | ||
| 5 | /** | |
| 6 | * Declares the server's overall authentication posture. | |
| 7 | * | |
| 8 | * This is a high-level policy label that tells clients how the environment is | |
| 9 | * expected to be accessed, not a transport detail and not an exhaustive list | |
| 10 | * of every accepted credential. | |
| 11 | * | |
| 12 | * Typical usage: | |
| 13 | * - rendered in auth/pairing UI so the user understands what kind of | |
| 14 | * environment they are connecting to | |
| 15 | * - used by clients to decide whether silent desktop bootstrap is expected or | |
| 16 | * whether an explicit pairing flow should be shown | |
| 17 | * | |
| 18 | * Meanings: | |
| 19 | * - `desktop-managed-local`: local desktop-managed environment with narrow | |
| 20 | * trusted bootstrap, intended to avoid login prompts on the same machine | |
| 21 | * - `loopback-browser`: standalone local server intended for browser pairing on | |
| 22 | * the same machine | |
| 23 | * - `remote-reachable`: environment intended to be reached from other devices | |
| 24 | * or networks, where explicit pairing/auth is expected | |
| 25 | * - `unsafe-no-auth`: intentionally unauthenticated mode; this is an explicit | |
| 26 | * unsafe escape hatch, not a normal deployment mode | |
| 27 | */ | |
| 28 | export const ServerAuthPolicy = Schema.Literals([ | |
| 29 | "desktop-managed-local", | |
| 30 | "loopback-browser", | |
| 31 | "remote-reachable", | |
| 32 | "unsafe-no-auth", | |
| 33 | ]); | |
| 34 | export type ServerAuthPolicy = typeof ServerAuthPolicy.Type; | |
| 35 | ||
| 36 | /** | |
| 37 | * A credential type that can be exchanged for a real authenticated session. | |
| 38 | * | |
| 39 | * Bootstrap methods are for establishing trust at the start of a connection or | |
| 40 | * pairing flow. They are not the long-lived credential used for ordinary | |
| 41 | * authenticated HTTP / WebSocket traffic after pairing succeeds. | |
| 42 | * | |
| 43 | * Current methods: | |
| 44 | * - `desktop-bootstrap`: a trusted local desktop handoff, used so the desktop | |
| 45 | * shell can pair the renderer without a login screen | |
| 46 | * - `one-time-token`: a short-lived pairing token, suitable for manual pairing | |
| 47 | * flows such as `/pair?token=...` | |
| 48 | */ | |
| 49 | export const ServerAuthBootstrapMethod = Schema.Literals(["desktop-bootstrap", "one-time-token"]); | |
| 50 | export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type; | |
| 51 | ||
| 52 | /** | |
| 53 | * A credential type accepted for steady-state authenticated requests after a | |
| 54 | * client has already paired. | |
| 55 | * | |
| 56 | * These methods are used by the server-wide auth layer for privileged HTTP and | |
| 57 | * WebSocket access. They are distinct from bootstrap methods so clients can | |
| 58 | * reason clearly about "pair first, then use session auth". | |
| 59 | * | |
| 60 | * Current methods: | |
| 61 | * - `browser-session-cookie`: cookie-backed browser session, used by the web | |
| 62 | * app after bootstrap/pairing | |
| 63 | * - `bearer-session-token`: token-based session suitable for non-cookie or | |
| 64 | * non-browser clients | |
| 65 | */ | |
| 66 | export const ServerAuthSessionMethod = Schema.Literals([ | |
| 67 | "browser-session-cookie", | |
| 68 | "bearer-session-token", | |
| 69 | ]); | |
| 70 | export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; | |
| 71 | ||
| 72 | export const AuthSessionRole = Schema.Literals(["owner", "client"]); | |
| 73 | export type AuthSessionRole = typeof AuthSessionRole.Type; | |
| 74 | ||
| 75 | /** | |
| 76 | * Server-advertised auth capabilities for a specific execution environment. | |
| 77 | * | |
| 78 | * Clients should treat this as the authoritative description of how that | |
| 79 | * environment expects to be paired and how authenticated requests should be | |
| 80 | * made afterward. | |
| 81 | * | |
| 82 | * Field meanings: | |
| 83 | * - `policy`: high-level auth posture for the environment | |
| 84 | * - `bootstrapMethods`: pairing/bootstrap methods the server is currently | |
| 85 | * willing to accept | |
| 86 | * - `sessionMethods`: authenticated request/session methods the server supports | |
| 87 | * once pairing is complete | |
| 88 | * - `sessionCookieName`: cookie name clients should expect when | |
| 89 | * `browser-session-cookie` is in use | |
| 90 | * | |
| 91 | * This descriptor is intentionally capability-oriented. It lets clients choose | |
| 92 | * the right UX without embedding server-specific auth logic or assuming a | |
| 93 | * single access method. | |
| 94 | */ | |
| 95 | export const ServerAuthDescriptor = Schema.Struct({ | |
| 96 | policy: ServerAuthPolicy, | |
| 97 | bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod), | |
| 98 | sessionMethods: Schema.Array(ServerAuthSessionMethod), | |
| 99 | sessionCookieName: TrimmedNonEmptyString, | |
| 100 | }); | |
| 101 | export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type; | |
| 102 | ||
| 103 | export const AuthBootstrapInput = Schema.Struct({ | |
| 104 | credential: TrimmedNonEmptyString, | |
| 105 | }); | |
| 106 | export type AuthBootstrapInput = typeof AuthBootstrapInput.Type; | |
| 107 | ||
| 108 | export const AuthBootstrapResult = Schema.Struct({ | |
| 109 | authenticated: Schema.Literal(true), | |
| 110 | role: AuthSessionRole, | |
| 111 | sessionMethod: ServerAuthSessionMethod, | |
| 112 | expiresAt: Schema.DateTimeUtc, | |
| 113 | }); | |
| 114 | export type AuthBootstrapResult = typeof AuthBootstrapResult.Type; | |
| 115 | ||
| 116 | export const AuthBearerBootstrapResult = Schema.Struct({ | |
| 117 | authenticated: Schema.Literal(true), | |
| 118 | role: AuthSessionRole, | |
| 119 | sessionMethod: Schema.Literal("bearer-session-token"), | |
| 120 | expiresAt: Schema.DateTimeUtc, | |
| 121 | sessionToken: TrimmedNonEmptyString, | |
| 122 | }); | |
| 123 | export type AuthBearerBootstrapResult = typeof AuthBearerBootstrapResult.Type; | |
| 124 | ||
| 125 | export const AuthWebSocketTokenResult = Schema.Struct({ | |
| 126 | token: TrimmedNonEmptyString, | |
| 127 | expiresAt: Schema.DateTimeUtc, | |
| 128 | }); | |
| 129 | export type AuthWebSocketTokenResult = typeof AuthWebSocketTokenResult.Type; | |
| 130 | ||
| 131 | export const AuthPairingCredentialResult = Schema.Struct({ | |
| 132 | id: TrimmedNonEmptyString, | |
| 133 | credential: TrimmedNonEmptyString, | |
| 134 | label: Schema.optionalKey(TrimmedNonEmptyString), | |
| 135 | expiresAt: Schema.DateTimeUtc, | |
| 136 | }); | |
| 137 | export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Type; | |
| 138 | ||
| 139 | export const AuthPairingLink = Schema.Struct({ | |
| 140 | id: TrimmedNonEmptyString, | |
| 141 | credential: TrimmedNonEmptyString, | |
| 142 | role: AuthSessionRole, | |
| 143 | subject: TrimmedNonEmptyString, | |
| 144 | label: Schema.optionalKey(TrimmedNonEmptyString), | |
| 145 | createdAt: Schema.DateTimeUtc, | |
| 146 | expiresAt: Schema.DateTimeUtc, | |
| 147 | }); | |
| 148 | export type AuthPairingLink = typeof AuthPairingLink.Type; | |
| 149 | ||
| 150 | export const AuthClientMetadataDeviceType = Schema.Literals([ | |
| 151 | "desktop", | |
| 152 | "mobile", | |
| 153 | "tablet", | |
| 154 | "bot", | |
| 155 | "unknown", | |
| 156 | ]); | |
| 157 | export type AuthClientMetadataDeviceType = typeof AuthClientMetadataDeviceType.Type; | |
| 158 | ||
| 159 | export const AuthClientMetadata = Schema.Struct({ | |
| 160 | label: Schema.optionalKey(TrimmedNonEmptyString), | |
| 161 | ipAddress: Schema.optionalKey(TrimmedNonEmptyString), | |
| 162 | userAgent: Schema.optionalKey(TrimmedNonEmptyString), | |
| 163 | deviceType: AuthClientMetadataDeviceType, | |
| 164 | os: Schema.optionalKey(TrimmedNonEmptyString), | |
| 165 | browser: Schema.optionalKey(TrimmedNonEmptyString), | |
| 166 | }); | |
| 167 | export type AuthClientMetadata = typeof AuthClientMetadata.Type; | |
| 168 | ||
| 169 | export const AuthClientSession = Schema.Struct({ | |
| 170 | sessionId: AuthSessionId, | |
| 171 | subject: TrimmedNonEmptyString, | |
| 172 | role: AuthSessionRole, | |
| 173 | method: ServerAuthSessionMethod, | |
| 174 | client: AuthClientMetadata, | |
| 175 | issuedAt: Schema.DateTimeUtc, | |
| 176 | expiresAt: Schema.DateTimeUtc, | |
| 177 | lastConnectedAt: Schema.NullOr(Schema.DateTimeUtc), | |
| 178 | connected: Schema.Boolean, | |
| 179 | current: Schema.Boolean, | |
| 180 | }); | |
| 181 | export type AuthClientSession = typeof AuthClientSession.Type; | |
| 182 | ||
| 183 | export const AuthAccessSnapshot = Schema.Struct({ | |
| 184 | pairingLinks: Schema.Array(AuthPairingLink), | |
| 185 | clientSessions: Schema.Array(AuthClientSession), | |
| 186 | }); | |
| 187 | export type AuthAccessSnapshot = typeof AuthAccessSnapshot.Type; | |
| 188 | ||
| 189 | export const AuthAccessStreamSnapshotEvent = Schema.Struct({ | |
| 190 | version: Schema.Literal(1), | |
| 191 | revision: Schema.Number, | |
| 192 | type: Schema.Literal("snapshot"), | |
| 193 | payload: AuthAccessSnapshot, | |
| 194 | }); | |
| 195 | export type AuthAccessStreamSnapshotEvent = typeof AuthAccessStreamSnapshotEvent.Type; | |
| 196 | ||
| 197 | export const AuthAccessStreamPairingLinkUpsertedEvent = Schema.Struct({ | |
| 198 | version: Schema.Literal(1), | |
| 199 | revision: Schema.Number, | |
| 200 | type: Schema.Literal("pairingLinkUpserted"), | |
| 201 | payload: AuthPairingLink, | |
| 202 | }); | |
| 203 | export type AuthAccessStreamPairingLinkUpsertedEvent = | |
| 204 | typeof AuthAccessStreamPairingLinkUpsertedEvent.Type; | |
| 205 | ||
| 206 | export const AuthAccessStreamPairingLinkRemovedEvent = Schema.Struct({ | |
| 207 | version: Schema.Literal(1), | |
| 208 | revision: Schema.Number, | |
| 209 | type: Schema.Literal("pairingLinkRemoved"), | |
| 210 | payload: Schema.Struct({ | |
| 211 | id: TrimmedNonEmptyString, | |
| 212 | }), | |
| 213 | }); | |
| 214 | export type AuthAccessStreamPairingLinkRemovedEvent = | |
| 215 | typeof AuthAccessStreamPairingLinkRemovedEvent.Type; | |
| 216 | ||
| 217 | export const AuthAccessStreamClientUpsertedEvent = Schema.Struct({ | |
| 218 | version: Schema.Literal(1), | |
| 219 | revision: Schema.Number, | |
| 220 | type: Schema.Literal("clientUpserted"), | |
| 221 | payload: AuthClientSession, | |
| 222 | }); | |
| 223 | export type AuthAccessStreamClientUpsertedEvent = typeof AuthAccessStreamClientUpsertedEvent.Type; | |
| 224 | ||
| 225 | export const AuthAccessStreamClientRemovedEvent = Schema.Struct({ | |
| 226 | version: Schema.Literal(1), | |
| 227 | revision: Schema.Number, | |
| 228 | type: Schema.Literal("clientRemoved"), | |
| 229 | payload: Schema.Struct({ | |
| 230 | sessionId: AuthSessionId, | |
| 231 | }), | |
| 232 | }); | |
| 233 | export type AuthAccessStreamClientRemovedEvent = typeof AuthAccessStreamClientRemovedEvent.Type; | |
| 234 | ||
| 235 | export const AuthAccessStreamEvent = Schema.Union([ | |
| 236 | AuthAccessStreamSnapshotEvent, | |
| 237 | AuthAccessStreamPairingLinkUpsertedEvent, | |
| 238 | AuthAccessStreamPairingLinkRemovedEvent, | |
| 239 | AuthAccessStreamClientUpsertedEvent, | |
| 240 | AuthAccessStreamClientRemovedEvent, | |
| 241 | ]); | |
| 242 | export type AuthAccessStreamEvent = typeof AuthAccessStreamEvent.Type; | |
| 243 | ||
| 244 | export const AuthRevokePairingLinkInput = Schema.Struct({ | |
| 245 | id: TrimmedNonEmptyString, | |
| 246 | }); | |
| 247 | export type AuthRevokePairingLinkInput = typeof AuthRevokePairingLinkInput.Type; | |
| 248 | ||
| 249 | export const AuthRevokeClientSessionInput = Schema.Struct({ | |
| 250 | sessionId: AuthSessionId, | |
| 251 | }); | |
| 252 | export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.Type; | |
| 253 | ||
| 254 | export const AuthCreatePairingCredentialInput = Schema.Struct({ | |
| 255 | label: Schema.optionalKey(TrimmedNonEmptyString), | |
| 256 | }); | |
| 257 | export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type; | |
| 258 | ||
| 259 | export const AuthSessionState = Schema.Struct({ | |
| 260 | authenticated: Schema.Boolean, | |
| 261 | auth: ServerAuthDescriptor, | |
| 262 | role: Schema.optionalKey(AuthSessionRole), | |
| 263 | sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), | |
| 264 | expiresAt: Schema.optionalKey(Schema.DateTimeUtc), | |
| 265 | }); | |
| 266 | export type AuthSessionState = typeof AuthSessionState.Type; |
packages/contracts/src/baseSchemas.ts+2| ... | ... | @@ -29,6 +29,8 @@ export const MessageId = makeEntityId("MessageId"); |
| 29 | 29 | export type MessageId = typeof MessageId.Type; |
| 30 | 30 | export const TurnId = makeEntityId("TurnId"); |
| 31 | 31 | export type TurnId = typeof TurnId.Type; |
| 32 | export const AuthSessionId = makeEntityId("AuthSessionId"); | |
| 33 | export type AuthSessionId = typeof AuthSessionId.Type; | |
| 32 | 34 | |
| 33 | 35 | export const ProviderItemId = makeEntityId("ProviderItemId"); |
| 34 | 36 | export type ProviderItemId = typeof ProviderItemId.Type; |
packages/contracts/src/index.ts+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | export * from "./baseSchemas"; |
| 2 | export * from "./auth"; | |
| 2 | 3 | export * from "./environment"; |
| 3 | 4 | export * from "./ipc"; |
| 4 | 5 | export * from "./terminal"; |
packages/contracts/src/ipc.ts+13-2| ... | ... | @@ -107,12 +107,23 @@ export interface DesktopUpdateCheckResult { |
| 107 | 107 | |
| 108 | 108 | export interface DesktopEnvironmentBootstrap { |
| 109 | 109 | label: string; |
| 110 | wsUrl: string | null; | |
| 110 | httpBaseUrl: string | null; | |
| 111 | wsBaseUrl: string | null; | |
| 112 | bootstrapToken?: string; | |
| 113 | } | |
| 114 | ||
| 115 | export type DesktopServerExposureMode = "local-only" | "network-accessible"; | |
| 116 | ||
| 117 | export interface DesktopServerExposureState { | |
| 118 | mode: DesktopServerExposureMode; | |
| 119 | endpointUrl: string | null; | |
| 120 | advertisedHost: string | null; | |
| 111 | 121 | } |
| 112 | 122 | |
| 113 | 123 | export interface DesktopBridge { |
| 114 | getWsUrl: () => string | null; | |
| 115 | 124 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |
| 125 | getServerExposureState: () => Promise<DesktopServerExposureState>; | |
| 126 | setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>; | |
| 116 | 127 | pickFolder: () => Promise<string | null>; |
| 117 | 128 | confirm: (message: string) => Promise<boolean>; |
| 118 | 129 | setTheme: (theme: DesktopTheme) => Promise<void>; |
packages/contracts/src/model.ts+1| ... | ... | @@ -66,6 +66,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, |
| 66 | 66 | |
| 67 | 67 | export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = { |
| 68 | 68 | codex: { |
| 69 | "gpt-5-codex": "gpt-5.4", | |
| 69 | 70 | "5.4": "gpt-5.4", |
| 70 | 71 | "5.3": "gpt-5.3-codex", |
| 71 | 72 | "gpt-5.3": "gpt-5.3-codex", |
packages/contracts/src/rpc.ts+9| ... | ... | @@ -3,6 +3,7 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 3 | 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 4 | 4 | |
| 5 | 5 | import { OpenError, OpenInEditorInput } from "./editor"; |
| 6 | import { AuthAccessStreamEvent } from "./auth"; | |
| 6 | 7 | import { |
| 7 | 8 | GitActionProgressEvent, |
| 8 | 9 | GitCheckoutInput, |
| ... | ... | @@ -118,6 +119,7 @@ export const WS_METHODS = { |
| 118 | 119 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 119 | 120 | subscribeServerConfig: "subscribeServerConfig", |
| 120 | 121 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| 122 | subscribeAuthAccess: "subscribeAuthAccess", | |
| 121 | 123 | } as const; |
| 122 | 124 | |
| 123 | 125 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { |
| ... | ... | @@ -334,6 +336,12 @@ export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServer |
| 334 | 336 | stream: true, |
| 335 | 337 | }); |
| 336 | 338 | |
| 339 | export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { | |
| 340 | payload: Schema.Struct({}), | |
| 341 | success: AuthAccessStreamEvent, | |
| 342 | stream: true, | |
| 343 | }); | |
| 344 | ||
| 337 | 345 | export const WsRpcGroup = RpcGroup.make( |
| 338 | 346 | WsServerGetConfigRpc, |
| 339 | 347 | WsServerRefreshProvidersRpc, |
| ... | ... | @@ -365,6 +373,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 365 | 373 | WsSubscribeTerminalEventsRpc, |
| 366 | 374 | WsSubscribeServerConfigRpc, |
| 367 | 375 | WsSubscribeServerLifecycleRpc, |
| 376 | WsSubscribeAuthAccessRpc, | |
| 368 | 377 | WsOrchestrationGetSnapshotRpc, |
| 369 | 378 | WsOrchestrationDispatchCommandRpc, |
| 370 | 379 | WsOrchestrationGetTurnDiffRpc, |
packages/contracts/src/server.ts+2| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { Schema } from "effect"; |
| 2 | 2 | import { ExecutionEnvironmentDescriptor } from "./environment"; |
| 3 | import { ServerAuthDescriptor } from "./auth"; | |
| 3 | 4 | import { |
| 4 | 5 | IsoDateTime, |
| 5 | 6 | NonNegativeInt, |
| ... | ... | @@ -85,6 +86,7 @@ export type ServerObservability = typeof ServerObservability.Type; |
| 85 | 86 | |
| 86 | 87 | export const ServerConfig = Schema.Struct({ |
| 87 | 88 | environment: ExecutionEnvironmentDescriptor, |
| 89 | auth: ServerAuthDescriptor, | |
| 88 | 90 | cwd: TrimmedNonEmptyString, |
| 89 | 91 | keybindingsConfigPath: TrimmedNonEmptyString, |
| 90 | 92 | keybindings: ResolvedKeybindingsConfig, |
packages/shared/src/model.test.ts+1| ... | ... | @@ -48,6 +48,7 @@ const claudeCaps: ModelCapabilities = { |
| 48 | 48 | |
| 49 | 49 | describe("normalizeModelSlug", () => { |
| 50 | 50 | it("maps known aliases to canonical slugs", () => { |
| 51 | expect(normalizeModelSlug("gpt-5-codex")).toBe("gpt-5.4"); | |
| 51 | 52 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); |
| 52 | 53 | expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); |
| 53 | 54 | }); |
scripts/dev-runner.test.ts+5-13| ... | ... | @@ -54,7 +54,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 54 | 54 | serverOffset: 0, |
| 55 | 55 | webOffset: 0, |
| 56 | 56 | t3Home: undefined, |
| 57 | authToken: undefined, | |
| 58 | 57 | noBrowser: undefined, |
| 59 | 58 | autoBootstrapProjectFromCwd: undefined, |
| 60 | 59 | logWebSocketEvents: undefined, |
| ... | ... | @@ -75,7 +74,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 75 | 74 | serverOffset: 0, |
| 76 | 75 | webOffset: 0, |
| 77 | 76 | t3Home: "/tmp/custom-t3", |
| 78 | authToken: "secret", | |
| 79 | 77 | noBrowser: true, |
| 80 | 78 | autoBootstrapProjectFromCwd: false, |
| 81 | 79 | logWebSocketEvents: true, |
| ... | ... | @@ -105,7 +103,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 105 | 103 | serverOffset: 0, |
| 106 | 104 | webOffset: 0, |
| 107 | 105 | t3Home: undefined, |
| 108 | authToken: undefined, | |
| 109 | 106 | noBrowser: undefined, |
| 110 | 107 | autoBootstrapProjectFromCwd: undefined, |
| 111 | 108 | logWebSocketEvents: undefined, |
| ... | ... | @@ -127,7 +124,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 127 | 124 | serverOffset: 0, |
| 128 | 125 | webOffset: 0, |
| 129 | 126 | t3Home: undefined, |
| 130 | authToken: undefined, | |
| 131 | 127 | noBrowser: undefined, |
| 132 | 128 | autoBootstrapProjectFromCwd: undefined, |
| 133 | 129 | logWebSocketEvents: false, |
| ... | ... | @@ -148,7 +144,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 148 | 144 | serverOffset: 0, |
| 149 | 145 | webOffset: 0, |
| 150 | 146 | t3Home: "/tmp/my-t3", |
| 151 | authToken: undefined, | |
| 152 | 147 | noBrowser: undefined, |
| 153 | 148 | autoBootstrapProjectFromCwd: undefined, |
| 154 | 149 | logWebSocketEvents: undefined, |
| ... | ... | @@ -161,13 +156,12 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 161 | 156 | }), |
| 162 | 157 | ); |
| 163 | 158 | |
| 164 | it.effect("does not export backend bootstrap env for dev:desktop", () => | |
| 159 | it.effect("pins desktop dev to a stable backend port and websocket url", () => | |
| 165 | 160 | Effect.gen(function* () { |
| 166 | 161 | const env = yield* createDevRunnerEnv({ |
| 167 | 162 | mode: "dev:desktop", |
| 168 | 163 | baseEnv: { |
| 169 | 164 | T3CODE_PORT: "3773", |
| 170 | T3CODE_AUTH_TOKEN: "stale-token", | |
| 171 | 165 | T3CODE_MODE: "web", |
| 172 | 166 | T3CODE_NO_BROWSER: "0", |
| 173 | 167 | T3CODE_HOST: "0.0.0.0", |
| ... | ... | @@ -176,7 +170,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 176 | 170 | serverOffset: 0, |
| 177 | 171 | webOffset: 0, |
| 178 | 172 | t3Home: "/tmp/my-t3", |
| 179 | authToken: "fresh-token", | |
| 180 | 173 | noBrowser: true, |
| 181 | 174 | autoBootstrapProjectFromCwd: undefined, |
| 182 | 175 | logWebSocketEvents: undefined, |
| ... | ... | @@ -187,14 +180,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 187 | 180 | |
| 188 | 181 | assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); |
| 189 | 182 | assert.equal(env.PORT, "5733"); |
| 190 | assert.equal(env.ELECTRON_RENDERER_PORT, "5733"); | |
| 191 | assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:5733"); | |
| 192 | assert.equal(env.T3CODE_PORT, undefined); | |
| 193 | assert.equal(env.T3CODE_AUTH_TOKEN, undefined); | |
| 183 | assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); | |
| 184 | assert.equal(env.HOST, "127.0.0.1"); | |
| 185 | assert.equal(env.T3CODE_PORT, "4222"); | |
| 194 | 186 | assert.equal(env.T3CODE_MODE, undefined); |
| 195 | 187 | assert.equal(env.T3CODE_NO_BROWSER, undefined); |
| 196 | 188 | assert.equal(env.T3CODE_HOST, undefined); |
| 197 | assert.equal(env.VITE_WS_URL, undefined); | |
| 189 | assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); | |
| 198 | 190 | }), |
| 199 | 191 | ); |
| 200 | 192 | }); |
scripts/dev-runner.ts+7-20| ... | ... | @@ -13,6 +13,7 @@ const BASE_SERVER_PORT = 3773; |
| 13 | 13 | const BASE_WEB_PORT = 5733; |
| 14 | 14 | const MAX_HASH_OFFSET = 3000; |
| 15 | 15 | const MAX_PORT = 65535; |
| 16 | const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; | |
| 16 | 17 | |
| 17 | 18 | export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => |
| 18 | 19 | path.join(homedir(), ".t3"), |
| ... | ... | @@ -120,7 +121,6 @@ interface CreateDevRunnerEnvInput { |
| 120 | 121 | readonly serverOffset: number; |
| 121 | 122 | readonly webOffset: number; |
| 122 | 123 | readonly t3Home: string | undefined; |
| 123 | readonly authToken: string | undefined; | |
| 124 | 124 | readonly noBrowser: boolean | undefined; |
| 125 | 125 | readonly autoBootstrapProjectFromCwd: boolean | undefined; |
| 126 | 126 | readonly logWebSocketEvents: boolean | undefined; |
| ... | ... | @@ -135,7 +135,6 @@ export function createDevRunnerEnv({ |
| 135 | 135 | serverOffset, |
| 136 | 136 | webOffset, |
| 137 | 137 | t3Home, |
| 138 | authToken, | |
| 139 | 138 | noBrowser, |
| 140 | 139 | autoBootstrapProjectFromCwd, |
| 141 | 140 | logWebSocketEvents, |
| ... | ... | @@ -152,8 +151,9 @@ export function createDevRunnerEnv({ |
| 152 | 151 | const output: NodeJS.ProcessEnv = { |
| 153 | 152 | ...baseEnv, |
| 154 | 153 | PORT: String(webPort), |
| 155 | ELECTRON_RENDERER_PORT: String(webPort), | |
| 156 | VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, | |
| 154 | VITE_DEV_SERVER_URL: | |
| 155 | devUrl?.toString() ?? | |
| 156 | `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, | |
| 157 | 157 | T3CODE_HOME: resolvedBaseDir, |
| 158 | 158 | }; |
| 159 | 159 | |
| ... | ... | @@ -161,9 +161,8 @@ export function createDevRunnerEnv({ |
| 161 | 161 | output.T3CODE_PORT = String(serverPort); |
| 162 | 162 | output.VITE_WS_URL = `ws://localhost:${serverPort}`; |
| 163 | 163 | } else { |
| 164 | delete output.T3CODE_PORT; | |
| 165 | delete output.VITE_WS_URL; | |
| 166 | delete output.T3CODE_AUTH_TOKEN; | |
| 164 | output.T3CODE_PORT = String(serverPort); | |
| 165 | output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; | |
| 167 | 166 | delete output.T3CODE_MODE; |
| 168 | 167 | delete output.T3CODE_NO_BROWSER; |
| 169 | 168 | delete output.T3CODE_HOST; |
| ... | ... | @@ -173,12 +172,6 @@ export function createDevRunnerEnv({ |
| 173 | 172 | output.T3CODE_HOST = host; |
| 174 | 173 | } |
| 175 | 174 | |
| 176 | if (!isDesktopMode && authToken !== undefined) { | |
| 177 | output.T3CODE_AUTH_TOKEN = authToken; | |
| 178 | } else if (!isDesktopMode) { | |
| 179 | delete output.T3CODE_AUTH_TOKEN; | |
| 180 | } | |
| 181 | ||
| 182 | 175 | if (!isDesktopMode && noBrowser !== undefined) { |
| 183 | 176 | output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; |
| 184 | 177 | } else if (!isDesktopMode) { |
| ... | ... | @@ -208,6 +201,7 @@ export function createDevRunnerEnv({ |
| 208 | 201 | } |
| 209 | 202 | |
| 210 | 203 | if (isDesktopMode) { |
| 204 | output.HOST = DESKTOP_DEV_LOOPBACK_HOST; | |
| 211 | 205 | delete output.T3CODE_DESKTOP_WS_URL; |
| 212 | 206 | } |
| 213 | 207 | |
| ... | ... | @@ -350,7 +344,6 @@ export function resolveModePortOffsets<R = NetService>({ |
| 350 | 344 | interface DevRunnerCliInput { |
| 351 | 345 | readonly mode: DevMode; |
| 352 | 346 | readonly t3Home: string | undefined; |
| 353 | readonly authToken: string | undefined; | |
| 354 | 347 | readonly noBrowser: boolean | undefined; |
| 355 | 348 | readonly autoBootstrapProjectFromCwd: boolean | undefined; |
| 356 | 349 | readonly logWebSocketEvents: boolean | undefined; |
| ... | ... | @@ -430,7 +423,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 430 | 423 | serverOffset, |
| 431 | 424 | webOffset, |
| 432 | 425 | t3Home: input.t3Home, |
| 433 | authToken: input.authToken, | |
| 434 | 426 | noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), |
| 435 | 427 | autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride( |
| 436 | 428 | input.autoBootstrapProjectFromCwd, |
| ... | ... | @@ -503,11 +495,6 @@ const devRunnerCli = Command.make("dev-runner", { |
| 503 | 495 | Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), |
| 504 | 496 | Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")), |
| 505 | 497 | ), |
| 506 | authToken: Flag.string("auth-token").pipe( | |
| 507 | Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."), | |
| 508 | Flag.withAlias("token"), | |
| 509 | Flag.withFallbackConfig(optionalStringConfig("T3CODE_AUTH_TOKEN")), | |
| 510 | ), | |
| 511 | 498 | noBrowser: Flag.boolean("no-browser").pipe( |
| 512 | 499 | Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."), |
| 513 | 500 | Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")), |
turbo.json+1-1| ... | ... | @@ -1,10 +1,10 @@ |
| 1 | 1 | { |
| 2 | 2 | "$schema": "https://turbo.build/schema.json", |
| 3 | 3 | "globalEnv": [ |
| 4 | "HOST", | |
| 4 | 5 | "PORT", |
| 5 | 6 | "VITE_WS_URL", |
| 6 | 7 | "VITE_DEV_SERVER_URL", |
| 7 | "ELECTRON_RENDERER_PORT", | |
| 8 | 8 | "T3CODE_LOG_WS_EVENTS", |
| 9 | 9 | "T3CODE_MODE", |
| 10 | 10 | "T3CODE_PORT", |