| 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,7 +72,7 @@ jobs: |
| 72 | - name: Verify preload bundle output | 72 | - name: Verify preload bundle output |
| 73 | run: | | 73 | run: | |
| 74 | test -f apps/desktop/dist-electron/preload.js | 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 | release_smoke: | 77 | release_smoke: |
| 78 | name: Release Smoke | 78 | name: Release Smoke |
.oxfmtrc.json+2-1| ... | @@ -9,7 +9,8 @@ | ... | @@ -9,7 +9,8 @@ |
| 9 | "bun.lock", | 9 | "bun.lock", |
| 10 | "*.tsbuildinfo", | 10 | "*.tsbuildinfo", |
| 11 | "**/routeTree.gen.ts", | 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 | "sortPackageJson": {} | 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,8 +5,17 @@ import { join } from "node:path"; |
| 5 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; | 5 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; |
| 6 | import { waitForResources } from "./wait-for-resources.mjs"; | 6 | import { waitForResources } from "./wait-for-resources.mjs"; |
| 7 | 7 | ||
| 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); | 8 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); |
| 9 | const devServerUrl = `http://localhost:${port}`; | 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 | const requiredFiles = [ | 19 | const requiredFiles = [ |
| 11 | "dist-electron/main.js", | 20 | "dist-electron/main.js", |
| 12 | "dist-electron/preload.js", | 21 | "dist-electron/preload.js", |
| ... | @@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200; | ... | @@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200; |
| 23 | await waitForResources({ | 32 | await waitForResources({ |
| 24 | baseDir: desktopDir, | 33 | baseDir: desktopDir, |
| 25 | files: requiredFiles, | 34 | files: requiredFiles, |
| 35 | tcpHost: devServer.hostname, | ||
| 26 | tcpPort: port, | 36 | tcpPort: port, |
| 27 | }); | 37 | }); |
| 28 | 38 | ||
| ... | @@ -62,10 +72,7 @@ function startApp() { | ... | @@ -62,10 +72,7 @@ function startApp() { |
| 62 | [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], | 72 | [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], |
| 63 | { | 73 | { |
| 64 | cwd: desktopDir, | 74 | cwd: desktopDir, |
| 65 | env: { | 75 | env: childEnv, |
| 66 | ...childEnv, | ||
| 67 | VITE_DEV_SERVER_URL: devServerUrl, | ||
| 68 | }, | ||
| 69 | stdio: "inherit", | 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,6 +19,8 @@ import type { MenuItemConstructorOptions } from "electron"; |
| 19 | import * as Effect from "effect/Effect"; | 19 | import * as Effect from "effect/Effect"; |
| 20 | import type { | 20 | import type { |
| 21 | DesktopTheme, | 21 | DesktopTheme, |
| 22 | DesktopServerExposureMode, | ||
| 23 | DesktopServerExposureState, | ||
| 22 | DesktopUpdateActionResult, | 24 | DesktopUpdateActionResult, |
| 23 | DesktopUpdateCheckResult, | 25 | DesktopUpdateCheckResult, |
| 24 | DesktopUpdateState, | 26 | DesktopUpdateState, |
| ... | @@ -29,7 +31,15 @@ import type { ContextMenuItem } from "@t3tools/contracts"; | ... | @@ -29,7 +31,15 @@ import type { ContextMenuItem } from "@t3tools/contracts"; |
| 29 | import { NetService } from "@t3tools/shared/Net"; | 31 | import { NetService } from "@t3tools/shared/Net"; |
| 30 | import { RotatingFileSink } from "@t3tools/shared/logging"; | 32 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 31 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | 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 | import { showDesktopConfirmDialog } from "./confirmDialog"; | 41 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 42 | import { resolveDesktopServerExposure } from "./serverExposure"; | ||
| 33 | import { syncShellEnvironment } from "./syncShellEnvironment"; | 43 | import { syncShellEnvironment } from "./syncShellEnvironment"; |
| 34 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; | 44 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; |
| 35 | import { | 45 | import { |
| ... | @@ -59,10 +69,12 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | ... | @@ -59,10 +69,12 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 59 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | 69 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 60 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 70 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 61 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | 71 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 62 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | ||
| 63 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | 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 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); | 75 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 65 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); | 76 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| 77 | const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); | ||
| 66 | const DESKTOP_SCHEME = "t3"; | 78 | const DESKTOP_SCHEME = "t3"; |
| 67 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); | 79 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); |
| 68 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | 80 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| ... | @@ -83,6 +95,7 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; | ... | @@ -83,6 +95,7 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; |
| 83 | const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; | 95 | const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; |
| 84 | const DESKTOP_UPDATE_CHANNEL = "latest"; | 96 | const DESKTOP_UPDATE_CHANNEL = "latest"; |
| 85 | const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; | 97 | const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; |
| 98 | const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | ||
| 86 | 99 | ||
| 87 | type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; | 100 | type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; |
| 88 | type LinuxDesktopNamedApp = Electron.App & { | 101 | type LinuxDesktopNamedApp = Electron.App & { |
| ... | @@ -92,8 +105,13 @@ type LinuxDesktopNamedApp = Electron.App & { | ... | @@ -92,8 +105,13 @@ type LinuxDesktopNamedApp = Electron.App & { |
| 92 | let mainWindow: BrowserWindow | null = null; | 105 | let mainWindow: BrowserWindow | null = null; |
| 93 | let backendProcess: ChildProcess.ChildProcess | null = null; | 106 | let backendProcess: ChildProcess.ChildProcess | null = null; |
| 94 | let backendPort = 0; | 107 | let backendPort = 0; |
| 95 | let backendAuthToken = ""; | 108 | let backendBindHost = DESKTOP_LOOPBACK_HOST; |
| 109 | let backendBootstrapToken = ""; | ||
| 110 | let backendHttpUrl = ""; | ||
| 96 | let backendWsUrl = ""; | 111 | let backendWsUrl = ""; |
| 112 | let backendEndpointUrl: string | null = null; | ||
| 113 | let backendAdvertisedHost: string | null = null; | ||
| 114 | let backendReadinessAbortController: AbortController | null = null; | ||
| 97 | let restartAttempt = 0; | 115 | let restartAttempt = 0; |
| 98 | let restartTimer: ReturnType<typeof setTimeout> | null = null; | 116 | let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 99 | let isQuitting = false; | 117 | let isQuitting = false; |
| ... | @@ -103,6 +121,8 @@ let desktopLogSink: RotatingFileSink | null = null; | ... | @@ -103,6 +121,8 @@ let desktopLogSink: RotatingFileSink | null = null; |
| 103 | let backendLogSink: RotatingFileSink | null = null; | 121 | let backendLogSink: RotatingFileSink | null = null; |
| 104 | let restoreStdIoCapture: (() => void) | null = null; | 122 | let restoreStdIoCapture: (() => void) | null = null; |
| 105 | let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); | 123 | let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); |
| 124 | let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH); | ||
| 125 | let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; | ||
| 106 | 126 | ||
| 107 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; | 127 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; |
| 108 | const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>(); | 128 | const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>(); |
| ... | @@ -141,17 +161,120 @@ function readPersistedBackendObservabilitySettings(): { | ... | @@ -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 | function backendChildEnv(): NodeJS.ProcessEnv { | 186 | function backendChildEnv(): NodeJS.ProcessEnv { |
| 145 | const env = { ...process.env }; | 187 | const env = { ...process.env }; |
| 146 | delete env.T3CODE_PORT; | 188 | delete env.T3CODE_PORT; |
| 147 | delete env.T3CODE_AUTH_TOKEN; | ||
| 148 | delete env.T3CODE_MODE; | 189 | delete env.T3CODE_MODE; |
| 149 | delete env.T3CODE_NO_BROWSER; | 190 | delete env.T3CODE_NO_BROWSER; |
| 150 | delete env.T3CODE_HOST; | 191 | delete env.T3CODE_HOST; |
| 151 | delete env.T3CODE_DESKTOP_WS_URL; | 192 | delete env.T3CODE_DESKTOP_WS_URL; |
| 193 | delete env.T3CODE_DESKTOP_LAN_ACCESS; | ||
| 194 | delete env.T3CODE_DESKTOP_LAN_HOST; | ||
| 152 | return env; | 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 | function writeDesktopLogHeader(message: string): void { | 278 | function writeDesktopLogHeader(message: string): void { |
| 156 | if (!desktopLogSink) return; | 279 | if (!desktopLogSink) return; |
| 157 | desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); | 280 | desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); |
| ... | @@ -199,6 +322,27 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { | ... | @@ -199,6 +322,27 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { |
| 199 | return null; | 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 | function writeDesktopStreamChunk( | 346 | function writeDesktopStreamChunk( |
| 203 | streamName: "stdout" | "stderr", | 347 | streamName: "stdout" | "stderr", |
| 204 | chunk: unknown, | 348 | chunk: unknown, |
| ... | @@ -543,10 +687,7 @@ function dispatchMenuAction(action: string): void { | ... | @@ -543,10 +687,7 @@ function dispatchMenuAction(action: string): void { |
| 543 | const send = () => { | 687 | const send = () => { |
| 544 | if (targetWindow.isDestroyed()) return; | 688 | if (targetWindow.isDestroyed()) return; |
| 545 | targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); | 689 | targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); |
| 546 | if (!targetWindow.isVisible()) { | 690 | revealWindow(targetWindow); |
| 547 | targetWindow.show(); | ||
| 548 | } | ||
| 549 | targetWindow.focus(); | ||
| 550 | }; | 691 | }; |
| 551 | 692 | ||
| 552 | if (targetWindow.webContents.isLoadingMainFrame()) { | 693 | if (targetWindow.webContents.isLoadingMainFrame()) { |
| ... | @@ -767,6 +908,26 @@ function clearUpdatePollTimer(): void { | ... | @@ -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 | function emitUpdateState(): void { | 931 | function emitUpdateState(): void { |
| 771 | for (const window of BrowserWindow.getAllWindows()) { | 932 | for (const window of BrowserWindow.getAllWindows()) { |
| 772 | if (window.isDestroyed()) continue; | 933 | if (window.isDestroyed()) continue; |
| ... | @@ -1036,7 +1197,8 @@ function startBackend(): void { | ... | @@ -1036,7 +1197,8 @@ function startBackend(): void { |
| 1036 | noBrowser: true, | 1197 | noBrowser: true, |
| 1037 | port: backendPort, | 1198 | port: backendPort, |
| 1038 | t3Home: BASE_DIR, | 1199 | t3Home: BASE_DIR, |
| 1039 | authToken: backendAuthToken, | 1200 | host: backendBindHost, |
| 1201 | desktopBootstrapToken: backendBootstrapToken, | ||
| 1040 | ...(backendObservabilitySettings.otlpTracesUrl | 1202 | ...(backendObservabilitySettings.otlpTracesUrl |
| 1041 | ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } | 1203 | ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } |
| 1042 | : {}), | 1204 | : {}), |
| ... | @@ -1095,6 +1257,7 @@ function startBackend(): void { | ... | @@ -1095,6 +1257,7 @@ function startBackend(): void { |
| 1095 | } | 1257 | } |
| 1096 | 1258 | ||
| 1097 | function stopBackend(): void { | 1259 | function stopBackend(): void { |
| 1260 | cancelBackendReadinessWait(); | ||
| 1098 | if (restartTimer) { | 1261 | if (restartTimer) { |
| 1099 | clearTimeout(restartTimer); | 1262 | clearTimeout(restartTimer); |
| 1100 | restartTimer = null; | 1263 | restartTimer = null; |
| ... | @@ -1116,6 +1279,7 @@ function stopBackend(): void { | ... | @@ -1116,6 +1279,7 @@ function stopBackend(): void { |
| 1116 | } | 1279 | } |
| 1117 | 1280 | ||
| 1118 | async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { | 1281 | async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { |
| 1282 | cancelBackendReadinessWait(); | ||
| 1119 | if (restartTimer) { | 1283 | if (restartTimer) { |
| 1120 | clearTimeout(restartTimer); | 1284 | clearTimeout(restartTimer); |
| 1121 | restartTimer = null; | 1285 | restartTimer = null; |
| ... | @@ -1168,19 +1332,38 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { | ... | @@ -1168,19 +1332,38 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { |
| 1168 | } | 1332 | } |
| 1169 | 1333 | ||
| 1170 | function registerIpcHandlers(): void { | 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 | ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | 1335 | ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); |
| 1177 | ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { | 1336 | ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { |
| 1178 | event.returnValue = { | 1337 | event.returnValue = { |
| 1179 | label: "Local environment", | 1338 | label: "Local environment", |
| 1180 | wsUrl: backendWsUrl || null, | 1339 | httpBaseUrl: backendHttpUrl || null, |
| 1340 | wsBaseUrl: backendWsUrl || null, | ||
| 1341 | bootstrapToken: backendBootstrapToken || undefined, | ||
| 1181 | } as const; | 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 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); | 1367 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); |
| 1185 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { | 1368 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { |
| 1186 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; | 1369 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; |
| ... | @@ -1346,14 +1529,19 @@ function getIconOption(): { icon: string } | Record<string, never> { | ... | @@ -1346,14 +1529,19 @@ function getIconOption(): { icon: string } | Record<string, never> { |
| 1346 | return iconPath ? { icon: iconPath } : {}; | 1529 | return iconPath ? { icon: iconPath } : {}; |
| 1347 | } | 1530 | } |
| 1348 | 1531 | ||
| 1532 | function getInitialWindowBackgroundColor(): string { | ||
| 1533 | return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; | ||
| 1534 | } | ||
| 1535 | |||
| 1349 | function createWindow(): BrowserWindow { | 1536 | function createWindow(): BrowserWindow { |
| 1350 | const window = new BrowserWindow({ | 1537 | const window = new BrowserWindow({ |
| 1351 | width: 1100, | 1538 | width: 1100, |
| 1352 | height: 780, | 1539 | height: 780, |
| 1353 | minWidth: 840, | 1540 | minWidth: 840, |
| 1354 | minHeight: 620, | 1541 | minHeight: 620, |
| 1355 | show: false, | 1542 | show: isDevelopment, |
| 1356 | autoHideMenuBar: true, | 1543 | autoHideMenuBar: true, |
| 1544 | backgroundColor: getInitialWindowBackgroundColor(), | ||
| 1357 | ...getIconOption(), | 1545 | ...getIconOption(), |
| 1358 | title: APP_DISPLAY_NAME, | 1546 | title: APP_DISPLAY_NAME, |
| 1359 | titleBarStyle: "hiddenInset", | 1547 | titleBarStyle: "hiddenInset", |
| ... | @@ -1410,15 +1598,20 @@ function createWindow(): BrowserWindow { | ... | @@ -1410,15 +1598,20 @@ function createWindow(): BrowserWindow { |
| 1410 | window.setTitle(APP_DISPLAY_NAME); | 1598 | window.setTitle(APP_DISPLAY_NAME); |
| 1411 | emitUpdateState(); | 1599 | emitUpdateState(); |
| 1412 | }); | 1600 | }); |
| 1413 | window.once("ready-to-show", () => { | 1601 | if (!isDevelopment) { |
| 1414 | window.show(); | 1602 | window.once("ready-to-show", () => { |
| 1415 | }); | 1603 | revealWindow(window); |
| 1604 | }); | ||
| 1605 | } | ||
| 1416 | 1606 | ||
| 1417 | if (isDevelopment) { | 1607 | if (isDevelopment) { |
| 1418 | void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); | 1608 | void window.loadURL(resolveDesktopDevServerUrl()); |
| 1419 | window.webContents.openDevTools({ mode: "detach" }); | 1609 | window.webContents.openDevTools({ mode: "detach" }); |
| 1610 | setImmediate(() => { | ||
| 1611 | revealWindow(window); | ||
| 1612 | }); | ||
| 1420 | } else { | 1613 | } else { |
| 1421 | void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); | 1614 | void window.loadURL(resolveDesktopWindowUrl()); |
| 1422 | } | 1615 | } |
| 1423 | 1616 | ||
| 1424 | window.on("closed", () => { | 1617 | window.on("closed", () => { |
| ... | @@ -1430,6 +1623,14 @@ function createWindow(): BrowserWindow { | ... | @@ -1430,6 +1623,14 @@ function createWindow(): BrowserWindow { |
| 1430 | return window; | 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 | // Override Electron's userData path before the `ready` event so that | 1634 | // Override Electron's userData path before the `ready` event so that |
| 1434 | // Chromium session data uses a filesystem-friendly directory name. | 1635 | // Chromium session data uses a filesystem-friendly directory name. |
| 1435 | // Must be called synchronously at the top level — before `app.whenReady()`. | 1636 | // Must be called synchronously at the top level — before `app.whenReady()`. |
| ... | @@ -1439,21 +1640,72 @@ configureAppIdentity(); | ... | @@ -1439,21 +1640,72 @@ configureAppIdentity(); |
| 1439 | 1640 | ||
| 1440 | async function bootstrap(): Promise<void> { | 1641 | async function bootstrap(): Promise<void> { |
| 1441 | writeDesktopLogHeader("bootstrap start"); | 1642 | writeDesktopLogHeader("bootstrap start"); |
| 1442 | backendPort = await Effect.service(NetService).pipe( | 1643 | const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); |
| 1443 | Effect.flatMap((net) => net.reserveLoopbackPort()), | 1644 | if (isDevelopment && configuredBackendPort === undefined) { |
| 1444 | Effect.provide(NetService.layer), | 1645 | throw new Error("T3CODE_PORT is required in desktop development."); |
| 1445 | Effect.runPromise, | 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}`); | 1660 | backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); |
| 1448 | backendAuthToken = Crypto.randomBytes(24).toString("hex"); | 1661 | if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { |
| 1449 | const baseUrl = `ws://127.0.0.1:${backendPort}`; | 1662 | writeDesktopLogHeader( |
| 1450 | backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; | 1663 | `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, |
| 1451 | writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); | 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 | registerIpcHandlers(); | 1683 | registerIpcHandlers(); |
| 1454 | writeDesktopLogHeader("bootstrap ipc handlers registered"); | 1684 | writeDesktopLogHeader("bootstrap ipc handlers registered"); |
| 1455 | startBackend(); | 1685 | startBackend(); |
| 1456 | writeDesktopLogHeader("bootstrap backend start requested"); | 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 | mainWindow = createWindow(); | 1709 | mainWindow = createWindow(); |
| 1458 | writeDesktopLogHeader("bootstrap main window created"); | 1710 | writeDesktopLogHeader("bootstrap main window created"); |
| 1459 | } | 1711 | } |
| ... | @@ -1463,6 +1715,7 @@ app.on("before-quit", () => { | ... | @@ -1463,6 +1715,7 @@ app.on("before-quit", () => { |
| 1463 | updateInstallInFlight = false; | 1715 | updateInstallInFlight = false; |
| 1464 | writeDesktopLogHeader("before-quit received"); | 1716 | writeDesktopLogHeader("before-quit received"); |
| 1465 | clearUpdatePollTimer(); | 1717 | clearUpdatePollTimer(); |
| 1718 | cancelBackendReadinessWait(); | ||
| 1466 | stopBackend(); | 1719 | stopBackend(); |
| 1467 | restoreStdIoCapture?.(); | 1720 | restoreStdIoCapture?.(); |
| 1468 | }); | 1721 | }); |
| ... | @@ -1476,13 +1729,19 @@ app | ... | @@ -1476,13 +1729,19 @@ app |
| 1476 | registerDesktopProtocol(); | 1729 | registerDesktopProtocol(); |
| 1477 | configureAutoUpdater(); | 1730 | configureAutoUpdater(); |
| 1478 | void bootstrap().catch((error) => { | 1731 | void bootstrap().catch((error) => { |
| 1732 | if (isBackendReadinessAborted(error) && isQuitting) { | ||
| 1733 | return; | ||
| 1734 | } | ||
| 1479 | handleFatalStartupError("bootstrap", error); | 1735 | handleFatalStartupError("bootstrap", error); |
| 1480 | }); | 1736 | }); |
| 1481 | 1737 | ||
| 1482 | app.on("activate", () => { | 1738 | app.on("activate", () => { |
| 1483 | if (BrowserWindow.getAllWindows().length === 0) { | 1739 | const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; |
| 1484 | mainWindow = createWindow(); | 1740 | if (existingWindow) { |
| 1741 | revealWindow(existingWindow); | ||
| 1742 | return; | ||
| 1485 | } | 1743 | } |
| 1744 | mainWindow = createWindow(); | ||
| 1486 | }); | 1745 | }); |
| 1487 | }) | 1746 | }) |
| 1488 | .catch((error) => { | 1747 | .catch((error) => { |
| ... | @@ -1501,6 +1760,7 @@ if (process.platform !== "win32") { | ... | @@ -1501,6 +1760,7 @@ if (process.platform !== "win32") { |
| 1501 | isQuitting = true; | 1760 | isQuitting = true; |
| 1502 | writeDesktopLogHeader("SIGINT received"); | 1761 | writeDesktopLogHeader("SIGINT received"); |
| 1503 | clearUpdatePollTimer(); | 1762 | clearUpdatePollTimer(); |
| 1763 | cancelBackendReadinessWait(); | ||
| 1504 | stopBackend(); | 1764 | stopBackend(); |
| 1505 | restoreStdIoCapture?.(); | 1765 | restoreStdIoCapture?.(); |
| 1506 | app.quit(); | 1766 | app.quit(); |
apps/desktop/src/preload.ts+4-5| ... | @@ -12,14 +12,11 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | ... | @@ -12,14 +12,11 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 12 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | 12 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 13 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | 13 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 14 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 14 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 15 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | ||
| 16 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | 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 | contextBridge.exposeInMainWorld("desktopBridge", { | 19 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 19 | getWsUrl: () => { | ||
| 20 | const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); | ||
| 21 | return typeof result === "string" ? result : null; | ||
| 22 | }, | ||
| 23 | getLocalEnvironmentBootstrap: () => { | 20 | getLocalEnvironmentBootstrap: () => { |
| 24 | const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | 21 | const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); |
| 25 | if (typeof result !== "object" || result === null) { | 22 | if (typeof result !== "object" || result === null) { |
| ... | @@ -27,6 +24,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -27,6 +24,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 27 | } | 24 | } |
| 28 | return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>; | 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 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), | 29 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), |
| 31 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), | 30 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), |
| 32 | setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), | 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,7 +43,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 43 | cwd: Option.none(), | 43 | cwd: Option.none(), |
| 44 | devUrl: Option.none(), | 44 | devUrl: Option.none(), |
| 45 | noBrowser: Option.none(), | 45 | noBrowser: Option.none(), |
| 46 | authToken: Option.none(), | ||
| 47 | bootstrapFd: Option.none(), | 46 | bootstrapFd: Option.none(), |
| 48 | autoBootstrapProjectFromCwd: Option.none(), | 47 | autoBootstrapProjectFromCwd: Option.none(), |
| 49 | logWebSocketEvents: Option.none(), | 48 | logWebSocketEvents: Option.none(), |
| ... | @@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 62 | T3CODE_HOME: baseDir, | 61 | T3CODE_HOME: baseDir, |
| 63 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | 62 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", |
| 64 | T3CODE_NO_BROWSER: "true", | 63 | T3CODE_NO_BROWSER: "true", |
| 65 | T3CODE_AUTH_TOKEN: "env-token", | ||
| 66 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", | 64 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", |
| 67 | T3CODE_LOG_WS_EVENTS: "true", | 65 | T3CODE_LOG_WS_EVENTS: "true", |
| 68 | }, | 66 | }, |
| ... | @@ -85,7 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -85,7 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 85 | staticDir: undefined, | 83 | staticDir: undefined, |
| 86 | devUrl: new URL("http://127.0.0.1:5173"), | 84 | devUrl: new URL("http://127.0.0.1:5173"), |
| 87 | noBrowser: true, | 85 | noBrowser: true, |
| 88 | authToken: "env-token", | 86 | desktopBootstrapToken: undefined, |
| 89 | autoBootstrapProjectFromCwd: false, | 87 | autoBootstrapProjectFromCwd: false, |
| 90 | logWebSocketEvents: true, | 88 | logWebSocketEvents: true, |
| 91 | }); | 89 | }); |
| ... | @@ -106,7 +104,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -106,7 +104,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 106 | cwd: Option.none(), | 104 | cwd: Option.none(), |
| 107 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), | 105 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), |
| 108 | noBrowser: Option.some(true), | 106 | noBrowser: Option.some(true), |
| 109 | authToken: Option.some("flag-token"), | ||
| 110 | bootstrapFd: Option.none(), | 107 | bootstrapFd: Option.none(), |
| 111 | autoBootstrapProjectFromCwd: Option.some(true), | 108 | autoBootstrapProjectFromCwd: Option.some(true), |
| 112 | logWebSocketEvents: Option.some(true), | 109 | logWebSocketEvents: Option.some(true), |
| ... | @@ -125,7 +122,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -125,7 +122,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 125 | T3CODE_HOME: join(os.tmpdir(), "ignored-base"), | 122 | T3CODE_HOME: join(os.tmpdir(), "ignored-base"), |
| 126 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | 123 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", |
| 127 | T3CODE_NO_BROWSER: "false", | 124 | T3CODE_NO_BROWSER: "false", |
| 128 | T3CODE_AUTH_TOKEN: "ignored-token", | ||
| 129 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", | 125 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", |
| 130 | T3CODE_LOG_WS_EVENTS: "false", | 126 | T3CODE_LOG_WS_EVENTS: "false", |
| 131 | }, | 127 | }, |
| ... | @@ -148,7 +144,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -148,7 +144,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 148 | staticDir: undefined, | 144 | staticDir: undefined, |
| 149 | devUrl: new URL("http://127.0.0.1:4173"), | 145 | devUrl: new URL("http://127.0.0.1:4173"), |
| 150 | noBrowser: true, | 146 | noBrowser: true, |
| 151 | authToken: "flag-token", | 147 | desktopBootstrapToken: undefined, |
| 152 | autoBootstrapProjectFromCwd: true, | 148 | autoBootstrapProjectFromCwd: true, |
| 153 | logWebSocketEvents: true, | 149 | logWebSocketEvents: true, |
| 154 | }); | 150 | }); |
| ... | @@ -166,7 +162,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -166,7 +162,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 166 | t3Home: baseDir, | 162 | t3Home: baseDir, |
| 167 | devUrl: "http://127.0.0.1:5173", | 163 | devUrl: "http://127.0.0.1:5173", |
| 168 | noBrowser: true, | 164 | noBrowser: true, |
| 169 | authToken: "bootstrap-token", | ||
| 170 | autoBootstrapProjectFromCwd: false, | 165 | autoBootstrapProjectFromCwd: false, |
| 171 | logWebSocketEvents: true, | 166 | logWebSocketEvents: true, |
| 172 | otlpTracesUrl: "http://localhost:4318/v1/traces", | 167 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| ... | @@ -183,7 +178,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -183,7 +178,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 183 | cwd: Option.none(), | 178 | cwd: Option.none(), |
| 184 | devUrl: Option.none(), | 179 | devUrl: Option.none(), |
| 185 | noBrowser: Option.none(), | 180 | noBrowser: Option.none(), |
| 186 | authToken: Option.none(), | ||
| 187 | bootstrapFd: Option.none(), | 181 | bootstrapFd: Option.none(), |
| 188 | autoBootstrapProjectFromCwd: Option.none(), | 182 | autoBootstrapProjectFromCwd: Option.none(), |
| 189 | logWebSocketEvents: Option.none(), | 183 | logWebSocketEvents: Option.none(), |
| ... | @@ -218,7 +212,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -218,7 +212,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 218 | staticDir: undefined, | 212 | staticDir: undefined, |
| 219 | devUrl: new URL("http://127.0.0.1:5173"), | 213 | devUrl: new URL("http://127.0.0.1:5173"), |
| 220 | noBrowser: true, | 214 | noBrowser: true, |
| 221 | authToken: "bootstrap-token", | 215 | desktopBootstrapToken: undefined, |
| 222 | autoBootstrapProjectFromCwd: false, | 216 | autoBootstrapProjectFromCwd: false, |
| 223 | logWebSocketEvents: true, | 217 | logWebSocketEvents: true, |
| 224 | }); | 218 | }); |
| ... | @@ -242,7 +236,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -242,7 +236,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 242 | cwd: Option.some(customCwd), | 236 | cwd: Option.some(customCwd), |
| 243 | devUrl: Option.some(new URL("http://127.0.0.1:5173")), | 237 | devUrl: Option.some(new URL("http://127.0.0.1:5173")), |
| 244 | noBrowser: Option.none(), | 238 | noBrowser: Option.none(), |
| 245 | authToken: Option.none(), | ||
| 246 | bootstrapFd: Option.none(), | 239 | bootstrapFd: Option.none(), |
| 247 | autoBootstrapProjectFromCwd: Option.none(), | 240 | autoBootstrapProjectFromCwd: Option.none(), |
| 248 | logWebSocketEvents: Option.none(), | 241 | logWebSocketEvents: Option.none(), |
| ... | @@ -285,7 +278,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -285,7 +278,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 285 | t3Home: "/tmp/t3-bootstrap-home", | 278 | t3Home: "/tmp/t3-bootstrap-home", |
| 286 | devUrl: "http://127.0.0.1:5173", | 279 | devUrl: "http://127.0.0.1:5173", |
| 287 | noBrowser: false, | 280 | noBrowser: false, |
| 288 | authToken: "bootstrap-token", | ||
| 289 | autoBootstrapProjectFromCwd: false, | 281 | autoBootstrapProjectFromCwd: false, |
| 290 | logWebSocketEvents: false, | 282 | logWebSocketEvents: false, |
| 291 | }); | 283 | }); |
| ... | @@ -300,7 +292,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -300,7 +292,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 300 | cwd: Option.none(), | 292 | cwd: Option.none(), |
| 301 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), | 293 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), |
| 302 | noBrowser: Option.none(), | 294 | noBrowser: Option.none(), |
| 303 | authToken: Option.some("flag-token"), | ||
| 304 | bootstrapFd: Option.none(), | 295 | bootstrapFd: Option.none(), |
| 305 | autoBootstrapProjectFromCwd: Option.none(), | 296 | autoBootstrapProjectFromCwd: Option.none(), |
| 306 | logWebSocketEvents: Option.none(), | 297 | logWebSocketEvents: Option.none(), |
| ... | @@ -338,7 +329,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -338,7 +329,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 338 | staticDir: undefined, | 329 | staticDir: undefined, |
| 339 | devUrl: new URL("http://127.0.0.1:4173"), | 330 | devUrl: new URL("http://127.0.0.1:4173"), |
| 340 | noBrowser: true, | 331 | noBrowser: true, |
| 341 | authToken: "flag-token", | 332 | desktopBootstrapToken: undefined, |
| 342 | autoBootstrapProjectFromCwd: true, | 333 | autoBootstrapProjectFromCwd: true, |
| 343 | logWebSocketEvents: true, | 334 | logWebSocketEvents: true, |
| 344 | }); | 335 | }); |
| ... | @@ -371,7 +362,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -371,7 +362,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 371 | cwd: Option.none(), | 362 | cwd: Option.none(), |
| 372 | devUrl: Option.none(), | 363 | devUrl: Option.none(), |
| 373 | noBrowser: Option.none(), | 364 | noBrowser: Option.none(), |
| 374 | authToken: Option.none(), | ||
| 375 | bootstrapFd: Option.none(), | 365 | bootstrapFd: Option.none(), |
| 376 | autoBootstrapProjectFromCwd: Option.none(), | 366 | autoBootstrapProjectFromCwd: Option.none(), |
| 377 | logWebSocketEvents: Option.none(), | 367 | logWebSocketEvents: Option.none(), |
| ... | @@ -402,7 +392,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { | ... | @@ -402,7 +392,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 402 | staticDir: resolved.staticDir, | 392 | staticDir: resolved.staticDir, |
| 403 | devUrl: undefined, | 393 | devUrl: undefined, |
| 404 | noBrowser: true, | 394 | noBrowser: true, |
| 405 | authToken: undefined, | 395 | desktopBootstrapToken: undefined, |
| 406 | autoBootstrapProjectFromCwd: false, | 396 | autoBootstrapProjectFromCwd: false, |
| 407 | logWebSocketEvents: false, | 397 | logWebSocketEvents: false, |
| 408 | }); | 398 | }); |
apps/server/src/cli.test.ts+103-7| ... | @@ -1,28 +1,41 @@ | ... | @@ -1,28 +1,41 @@ |
| 1 | import { mkdtempSync } from "node:fs"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | |||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { NetService } from "@t3tools/shared/Net"; | 6 | import { NetService } from "@t3tools/shared/Net"; |
| 3 | import { assert, it } from "@effect/vitest"; | 7 | import { assert, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 6 | import * as CliError from "effect/unstable/cli/CliError"; | 10 | import * as CliError from "effect/unstable/cli/CliError"; |
| 11 | import * as TestConsole from "effect/testing/TestConsole"; | ||
| 7 | import { Command } from "effect/unstable/cli"; | 12 | import { Command } from "effect/unstable/cli"; |
| 8 | 13 | ||
| 9 | import { cli } from "./cli.ts"; | 14 | import { cli } from "./cli.ts"; |
| 10 | 15 | ||
| 11 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | 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 | it.layer(NodeServices.layer)("cli log-level parsing", (it) => { | 31 | it.layer(NodeServices.layer)("cli log-level parsing", (it) => { |
| 14 | it.effect("accepts the built-in lowercase log-level flag values", () => | 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( | 33 | runCliWithRuntime(["--log-level", "debug", "--version"]), |
| 16 | Effect.provide(CliRuntimeLayer), | ||
| 17 | ), | ||
| 18 | ); | 34 | ); |
| 19 | 35 | ||
| 20 | it.effect("rejects invalid log-level casing before launching the server", () => | 36 | it.effect("rejects invalid log-level casing before launching the server", () => |
| 21 | Effect.gen(function* () { | 37 | Effect.gen(function* () { |
| 22 | const error = yield* Command.runWith(cli, { version: "0.0.0" })([ | 38 | const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip); |
| 23 | "--log-level", | ||
| 24 | "Debug", | ||
| 25 | ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip); | ||
| 26 | 39 | ||
| 27 | if (!CliError.isCliError(error)) { | 40 | if (!CliError.isCliError(error)) { |
| 28 | assert.fail(`Expected CliError, got ${String(error)}`); | 41 | assert.fail(`Expected CliError, got ${String(error)}`); |
| ... | @@ -34,4 +47,87 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { | ... | @@ -34,4 +47,87 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { |
| 34 | assert.equal(error.value, "Debug"); | 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,6 +1,20 @@ |
| 1 | import { NetService } from "@t3tools/shared/Net"; | 1 | import { NetService } from "@t3tools/shared/Net"; |
| 2 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | 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 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; | 18 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; |
| 5 | 19 | ||
| 6 | import { | 20 | import { |
| ... | @@ -15,6 +29,14 @@ import { | ... | @@ -15,6 +29,14 @@ import { |
| 15 | import { readBootstrapEnvelope } from "./bootstrap"; | 29 | import { readBootstrapEnvelope } from "./bootstrap"; |
| 16 | import { expandHomePath, resolveBaseDir } from "./os-jank"; | 30 | import { expandHomePath, resolveBaseDir } from "./os-jank"; |
| 17 | import { runServer } from "./server"; | 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 | const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); | 41 | const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); |
| 20 | 42 | ||
| ... | @@ -25,7 +47,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ | ... | @@ -25,7 +47,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ |
| 25 | t3Home: Schema.optional(Schema.String), | 47 | t3Home: Schema.optional(Schema.String), |
| 26 | devUrl: Schema.optional(Schema.URLFromString), | 48 | devUrl: Schema.optional(Schema.URLFromString), |
| 27 | noBrowser: Schema.optional(Schema.Boolean), | 49 | noBrowser: Schema.optional(Schema.Boolean), |
| 28 | authToken: Schema.optional(Schema.String), | 50 | desktopBootstrapToken: Schema.optional(Schema.String), |
| 29 | autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), | 51 | autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), |
| 30 | logWebSocketEvents: Schema.optional(Schema.Boolean), | 52 | logWebSocketEvents: Schema.optional(Schema.Boolean), |
| 31 | otlpTracesUrl: Schema.optional(Schema.String), | 53 | otlpTracesUrl: Schema.optional(Schema.String), |
| ... | @@ -58,11 +80,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe( | ... | @@ -58,11 +80,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe( |
| 58 | Flag.withDescription("Disable automatic browser opening."), | 80 | Flag.withDescription("Disable automatic browser opening."), |
| 59 | Flag.optional, | 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 | const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( | 83 | const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( |
| 67 | Flag.withSchema(Schema.Int), | 84 | Flag.withSchema(Schema.Int), |
| 68 | Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), | 85 | Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), |
| ... | @@ -117,10 +134,6 @@ const EnvServerConfig = Config.all({ | ... | @@ -117,10 +134,6 @@ const EnvServerConfig = Config.all({ |
| 117 | Config.option, | 134 | Config.option, |
| 118 | Config.map(Option.getOrUndefined), | 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 | bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( | 137 | bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( |
| 125 | Config.option, | 138 | Config.option, |
| 126 | Config.map(Option.getOrUndefined), | 139 | Config.map(Option.getOrUndefined), |
| ... | @@ -143,12 +156,16 @@ interface CliServerFlags { | ... | @@ -143,12 +156,16 @@ interface CliServerFlags { |
| 143 | readonly cwd: Option.Option<string>; | 156 | readonly cwd: Option.Option<string>; |
| 144 | readonly devUrl: Option.Option<URL>; | 157 | readonly devUrl: Option.Option<URL>; |
| 145 | readonly noBrowser: Option.Option<boolean>; | 158 | readonly noBrowser: Option.Option<boolean>; |
| 146 | readonly authToken: Option.Option<string>; | ||
| 147 | readonly bootstrapFd: Option.Option<number>; | 159 | readonly bootstrapFd: Option.Option<number>; |
| 148 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; | 160 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; |
| 149 | readonly logWebSocketEvents: Option.Option<boolean>; | 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 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | 169 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => |
| 153 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); | 170 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); |
| 154 | 171 | ||
| ... | @@ -248,13 +265,9 @@ export const resolveServerConfig = ( | ... | @@ -248,13 +265,9 @@ export const resolveServerConfig = ( |
| 248 | () => mode === "desktop", | 265 | () => mode === "desktop", |
| 249 | ), | 266 | ), |
| 250 | ); | 267 | ); |
| 251 | const authToken = Option.getOrUndefined( | 268 | const desktopBootstrapToken = Option.getOrUndefined( |
| 252 | resolveOptionPrecedence( | 269 | Option.flatMap(bootstrapEnvelope, (bootstrap) => |
| 253 | flags.authToken, | 270 | Option.fromUndefinedOr(bootstrap.desktopBootstrapToken), |
| 254 | Option.fromUndefinedOr(env.authToken), | ||
| 255 | Option.flatMap(bootstrapEnvelope, (bootstrap) => | ||
| 256 | Option.fromUndefinedOr(bootstrap.authToken), | ||
| 257 | ), | ||
| 258 | ), | 271 | ), |
| 259 | ); | 272 | ); |
| 260 | const autoBootstrapProjectFromCwd = resolveBooleanFlag( | 273 | const autoBootstrapProjectFromCwd = resolveBooleanFlag( |
| ... | @@ -327,7 +340,7 @@ export const resolveServerConfig = ( | ... | @@ -327,7 +340,7 @@ export const resolveServerConfig = ( |
| 327 | staticDir, | 340 | staticDir, |
| 328 | devUrl, | 341 | devUrl, |
| 329 | noBrowser, | 342 | noBrowser, |
| 330 | authToken, | 343 | desktopBootstrapToken, |
| 331 | autoBootstrapProjectFromCwd, | 344 | autoBootstrapProjectFromCwd, |
| 332 | logWebSocketEvents, | 345 | logWebSocketEvents, |
| 333 | }; | 346 | }; |
| ... | @@ -335,6 +348,110 @@ export const resolveServerConfig = ( | ... | @@ -335,6 +348,110 @@ export const resolveServerConfig = ( |
| 335 | return config; | 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 | const commandFlags = { | 455 | const commandFlags = { |
| 339 | mode: modeFlag, | 456 | mode: modeFlag, |
| 340 | port: portFlag, | 457 | port: portFlag, |
| ... | @@ -348,13 +465,216 @@ const commandFlags = { | ... | @@ -348,13 +465,216 @@ const commandFlags = { |
| 348 | ), | 465 | ), |
| 349 | devUrl: devUrlFlag, | 466 | devUrl: devUrlFlag, |
| 350 | noBrowser: noBrowserFlag, | 467 | noBrowser: noBrowserFlag, |
| 351 | authToken: authTokenFlag, | ||
| 352 | bootstrapFd: bootstrapFdFlag, | 468 | bootstrapFd: bootstrapFdFlag, |
| 353 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, | 469 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, |
| 354 | logWebSocketEvents: logWebSocketEventsFlag, | 470 | logWebSocketEvents: logWebSocketEventsFlag, |
| 355 | } as const; | 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 | Command.withDescription("Run the T3 Code server."), | 678 | Command.withDescription("Run the T3 Code server."), |
| 359 | Command.withHandler((flags) => | 679 | Command.withHandler((flags) => |
| 360 | Effect.gen(function* () { | 680 | Effect.gen(function* () { |
| ... | @@ -365,4 +685,14 @@ const rootCommand = Command.make("t3", commandFlags).pipe( | ... | @@ -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,6 +31,7 @@ export interface ServerDerivedPaths { |
| 31 | readonly terminalLogsDir: string; | 31 | readonly terminalLogsDir: string; |
| 32 | readonly anonymousIdPath: string; | 32 | readonly anonymousIdPath: string; |
| 33 | readonly environmentIdPath: string; | 33 | readonly environmentIdPath: string; |
| 34 | readonly secretsDir: string; | ||
| 34 | } | 35 | } |
| 35 | 36 | ||
| 36 | /** | 37 | /** |
| ... | @@ -55,7 +56,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { | ... | @@ -55,7 +56,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { |
| 55 | readonly staticDir: string | undefined; | 56 | readonly staticDir: string | undefined; |
| 56 | readonly devUrl: URL | undefined; | 57 | readonly devUrl: URL | undefined; |
| 57 | readonly noBrowser: boolean; | 58 | readonly noBrowser: boolean; |
| 58 | readonly authToken: string | undefined; | 59 | readonly desktopBootstrapToken: string | undefined; |
| 59 | readonly autoBootstrapProjectFromCwd: boolean; | 60 | readonly autoBootstrapProjectFromCwd: boolean; |
| 60 | readonly logWebSocketEvents: boolean; | 61 | readonly logWebSocketEvents: boolean; |
| 61 | } | 62 | } |
| ... | @@ -85,6 +86,7 @@ export const deriveServerPaths = Effect.fn(function* ( | ... | @@ -85,6 +86,7 @@ export const deriveServerPaths = Effect.fn(function* ( |
| 85 | terminalLogsDir: join(logsDir, "terminals"), | 86 | terminalLogsDir: join(logsDir, "terminals"), |
| 86 | anonymousIdPath: join(stateDir, "anonymous-id"), | 87 | anonymousIdPath: join(stateDir, "anonymous-id"), |
| 87 | environmentIdPath: join(stateDir, "environment-id"), | 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,7 +149,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS |
| 147 | logWebSocketEvents: false, | 149 | logWebSocketEvents: false, |
| 148 | port: 0, | 150 | port: 0, |
| 149 | host: undefined, | 151 | host: undefined, |
| 150 | authToken: undefined, | 152 | desktopBootstrapToken: undefined, |
| 151 | staticDir: undefined, | 153 | staticDir: undefined, |
| 152 | devUrl, | 154 | devUrl, |
| 153 | noBrowser: false, | 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,7 +32,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { |
| 32 | logWebSocketEvents: false, | 32 | logWebSocketEvents: false, |
| 33 | port: 0, | 33 | port: 0, |
| 34 | host: undefined, | 34 | host: undefined, |
| 35 | authToken: undefined, | 35 | desktopBootstrapToken: undefined, |
| 36 | staticDir: undefined, | 36 | staticDir: undefined, |
| 37 | devUrl: undefined, | 37 | devUrl: undefined, |
| 38 | noBrowser: false, | 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,6 +4,7 @@ import { Effect, FileSystem, Layer, Path, Random } from "effect"; |
| 4 | import { ServerConfig } from "../../config.ts"; | 4 | import { ServerConfig } from "../../config.ts"; |
| 5 | import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; | 5 | import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; |
| 6 | import { version } from "../../../package.json" with { type: "json" }; | 6 | import { version } from "../../../package.json" with { type: "json" }; |
| 7 | import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | ||
| 7 | 8 | ||
| 8 | function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { | 9 | function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { |
| 9 | switch (process.platform) { | 10 | switch (process.platform) { |
| ... | @@ -65,12 +66,9 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function | ... | @@ -65,12 +66,9 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function |
| 65 | 66 | ||
| 66 | const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw); | 67 | const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw); |
| 67 | const cwdBaseName = path.basename(serverConfig.cwd).trim(); | 68 | const cwdBaseName = path.basename(serverConfig.cwd).trim(); |
| 68 | const label = | 69 | const label = yield* resolveServerEnvironmentLabel({ |
| 69 | serverConfig.mode === "desktop" | 70 | cwdBaseName, |
| 70 | ? "Local environment" | 71 | }); |
| 71 | : cwdBaseName.length > 0 | ||
| 72 | ? cwdBaseName | ||
| 73 | : "T3 environment"; | ||
| 74 | 72 | ||
| 75 | const descriptor: ExecutionEnvironmentDescriptor = { | 73 | const descriptor: ExecutionEnvironmentDescriptor = { |
| 76 | environmentId, | 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,5 +1,5 @@ |
| 1 | import Mime from "@effect/platform-node/Mime"; | 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 | import { cast } from "effect/Function"; | 3 | import { cast } from "effect/Function"; |
| 4 | import { | 4 | import { |
| 5 | HttpBody, | 5 | HttpBody, |
| ... | @@ -17,14 +17,57 @@ import { | ... | @@ -17,14 +17,57 @@ import { |
| 17 | resolveAttachmentRelativePath, | 17 | resolveAttachmentRelativePath, |
| 18 | } from "./attachmentPaths"; | 18 | } from "./attachmentPaths"; |
| 19 | import { resolveAttachmentPathById } from "./attachmentStore"; | 19 | import { resolveAttachmentPathById } from "./attachmentStore"; |
| 20 | import { ServerConfig } from "./config"; | 20 | import { resolveStaticDir, ServerConfig } from "./config"; |
| 21 | import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; | 21 | import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; |
| 22 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; | 22 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; |
| 23 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; | 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 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; | 28 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; |
| 26 | 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>`; | 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 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; | 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 | class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ | 72 | class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ |
| 30 | readonly cause: unknown; | 73 | readonly cause: unknown; |
| ... | @@ -35,6 +78,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( | ... | @@ -35,6 +78,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 35 | "POST", | 78 | "POST", |
| 36 | OTLP_TRACES_PROXY_PATH, | 79 | OTLP_TRACES_PROXY_PATH, |
| 37 | Effect.gen(function* () { | 80 | Effect.gen(function* () { |
| 81 | yield* requireAuthenticatedRequest; | ||
| 38 | const request = yield* HttpServerRequest.HttpServerRequest; | 82 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 39 | const config = yield* ServerConfig; | 83 | const config = yield* ServerConfig; |
| 40 | const otlpTracesUrl = config.otlpTracesUrl; | 84 | const otlpTracesUrl = config.otlpTracesUrl; |
| ... | @@ -76,21 +120,14 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( | ... | @@ -76,21 +120,14 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( |
| 76 | Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), | 120 | Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), |
| 77 | ), | 121 | ), |
| 78 | ); | 122 | ); |
| 79 | }), | 123 | }).pipe(Effect.catchTag("AuthError", respondToAuthError)), |
| 80 | ).pipe( | ||
| 81 | Layer.provide( | ||
| 82 | HttpRouter.cors({ | ||
| 83 | allowedMethods: ["POST", "OPTIONS"], | ||
| 84 | allowedHeaders: ["content-type"], | ||
| 85 | maxAge: 600, | ||
| 86 | }), | ||
| 87 | ), | ||
| 88 | ); | 124 | ); |
| 89 | 125 | ||
| 90 | export const attachmentsRouteLayer = HttpRouter.add( | 126 | export const attachmentsRouteLayer = HttpRouter.add( |
| 91 | "GET", | 127 | "GET", |
| 92 | `${ATTACHMENTS_ROUTE_PREFIX}/*`, | 128 | `${ATTACHMENTS_ROUTE_PREFIX}/*`, |
| 93 | Effect.gen(function* () { | 129 | Effect.gen(function* () { |
| 130 | yield* requireAuthenticatedRequest; | ||
| 94 | const request = yield* HttpServerRequest.HttpServerRequest; | 131 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 95 | const url = HttpServerRequest.toURL(request); | 132 | const url = HttpServerRequest.toURL(request); |
| 96 | if (Option.isNone(url)) { | 133 | if (Option.isNone(url)) { |
| ... | @@ -139,13 +176,14 @@ export const attachmentsRouteLayer = HttpRouter.add( | ... | @@ -139,13 +176,14 @@ export const attachmentsRouteLayer = HttpRouter.add( |
| 139 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), | 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 | export const projectFaviconRouteLayer = HttpRouter.add( | 182 | export const projectFaviconRouteLayer = HttpRouter.add( |
| 146 | "GET", | 183 | "GET", |
| 147 | "/api/project-favicon", | 184 | "/api/project-favicon", |
| 148 | Effect.gen(function* () { | 185 | Effect.gen(function* () { |
| 186 | yield* requireAuthenticatedRequest; | ||
| 149 | const request = yield* HttpServerRequest.HttpServerRequest; | 187 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 150 | const url = HttpServerRequest.toURL(request); | 188 | const url = HttpServerRequest.toURL(request); |
| 151 | if (Option.isNone(url)) { | 189 | if (Option.isNone(url)) { |
| ... | @@ -179,7 +217,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( | ... | @@ -179,7 +217,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( |
| 179 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), | 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 | export const staticAndDevRouteLayer = HttpRouter.add( | 223 | export const staticAndDevRouteLayer = HttpRouter.add( |
| ... | @@ -193,11 +231,14 @@ export const staticAndDevRouteLayer = HttpRouter.add( | ... | @@ -193,11 +231,14 @@ export const staticAndDevRouteLayer = HttpRouter.add( |
| 193 | } | 231 | } |
| 194 | 232 | ||
| 195 | const config = yield* ServerConfig; | 233 | const config = yield* ServerConfig; |
| 196 | if (config.devUrl) { | 234 | if (config.devUrl && isLoopbackHostname(url.value.hostname)) { |
| 197 | return HttpServerResponse.redirect(config.devUrl.href, { status: 302 }); | 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 | return HttpServerResponse.text("No static directory configured and no dev URL set.", { | 242 | return HttpServerResponse.text("No static directory configured and no dev URL set.", { |
| 202 | status: 503, | 243 | status: 503, |
| 203 | }); | 244 | }); |
| ... | @@ -205,7 +246,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( | ... | @@ -205,7 +246,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( |
| 205 | 246 | ||
| 206 | const fileSystem = yield* FileSystem.FileSystem; | 247 | const fileSystem = yield* FileSystem.FileSystem; |
| 207 | const path = yield* Path.Path; | 248 | const path = yield* Path.Path; |
| 208 | const staticRoot = path.resolve(config.staticDir); | 249 | const staticRoot = path.resolve(staticDir); |
| 209 | const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; | 250 | const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; |
| 210 | const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); | 251 | const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); |
| 211 | const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); | 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,7 +2,7 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { assert, it } from "@effect/vitest"; | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | import { assertFailure } from "@effect/vitest/utils"; | 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 | import { ServerConfig } from "./config"; | 6 | import { ServerConfig } from "./config"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| ... | @@ -149,6 +149,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => { | ... | @@ -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 | it.effect("bootstraps default keybindings when config file is missing", () => | 169 | it.effect("bootstraps default keybindings when config file is missing", () => |
| 153 | Effect.gen(function* () { | 170 | Effect.gen(function* () { |
| 154 | const fs = yield* FileSystem.FileSystem; | 171 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/keybindings.ts+2-2| ... | @@ -322,7 +322,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( | ... | @@ -322,7 +322,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( |
| 322 | Predicate.isNotNull, | 322 | Predicate.isNotNull, |
| 323 | () => | 323 | () => |
| 324 | new SchemaIssue.InvalidValue(Option.some(rule), { | 324 | new SchemaIssue.InvalidValue(Option.some(rule), { |
| 325 | title: "Invalid keybinding rule", | 325 | message: "Invalid keybinding rule", |
| 326 | }), | 326 | }), |
| 327 | ), | 327 | ), |
| 328 | Effect.map((resolved) => resolved), | 328 | Effect.map((resolved) => resolved), |
| ... | @@ -334,7 +334,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( | ... | @@ -334,7 +334,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( |
| 334 | if (!key) { | 334 | if (!key) { |
| 335 | return yield* Effect.fail( | 335 | return yield* Effect.fail( |
| 336 | new SchemaIssue.InvalidValue(Option.some(resolved), { | 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,5 +101,7 @@ export type OrchestrationCommandReceiptRepositoryError = |
| 101 | | PersistenceDecodeError; | 101 | | PersistenceDecodeError; |
| 102 | 102 | ||
| 103 | export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; | 103 | export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; |
| 104 | export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; | ||
| 105 | export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; | ||
| 104 | 106 | ||
| 105 | export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; | 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,6 +32,9 @@ import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; |
| 32 | import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; | 32 | import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; |
| 33 | import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; | 33 | import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; |
| 34 | import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; | 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 | * Migration loader with all migrations defined inline. | 40 | * Migration loader with all migrations defined inline. |
| ... | @@ -63,6 +66,9 @@ export const migrationEntries = [ | ... | @@ -63,6 +66,9 @@ export const migrationEntries = [ |
| 63 | [17, "ProjectionThreadsArchivedAt", Migration0017], | 66 | [17, "ProjectionThreadsArchivedAt", Migration0017], |
| 64 | [18, "ProjectionThreadsArchivedAtIndex", Migration0018], | 67 | [18, "ProjectionThreadsArchivedAtIndex", Migration0018], |
| 65 | [19, "ProjectionSnapshotLookupIndexes", Migration0019], | 68 | [19, "ProjectionSnapshotLookupIndexes", Migration0019], |
| 69 | [20, "AuthAccessManagement", Migration0020], | ||
| 70 | [21, "AuthSessionClientMetadata", Migration0021], | ||
| 71 | [22, "AuthSessionLastConnectedAt", Migration0022], | ||
| 66 | ] as const; | 72 | ] as const; |
| 67 | 73 | ||
| 68 | export const makeMigrationLoader = (throughId?: number) => | 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,6 +43,7 @@ import { |
| 43 | } from "effect/unstable/http"; | 43 | } from "effect/unstable/http"; |
| 44 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | 44 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 45 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | 45 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; |
| 46 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 46 | import { vi } from "vitest"; | 47 | import { vi } from "vitest"; |
| 47 | 48 | ||
| 48 | import type { ServerConfigShape } from "./config.ts"; | 49 | import type { ServerConfigShape } from "./config.ts"; |
| ... | @@ -68,6 +69,7 @@ import { | ... | @@ -68,6 +69,7 @@ import { |
| 68 | type ProjectionSnapshotQueryShape, | 69 | type ProjectionSnapshotQueryShape, |
| 69 | } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 70 | } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 70 | import { PersistenceSqlError } from "./persistence/Errors.ts"; | 71 | import { PersistenceSqlError } from "./persistence/Errors.ts"; |
| 72 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; | ||
| 71 | import { | 73 | import { |
| 72 | ProviderRegistry, | 74 | ProviderRegistry, |
| 73 | type ProviderRegistryShape, | 75 | type ProviderRegistryShape, |
| ... | @@ -96,9 +98,12 @@ import { | ... | @@ -96,9 +98,12 @@ import { |
| 96 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | 98 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; |
| 97 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | 99 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 98 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 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 | const defaultProjectId = ProjectId.makeUnsafe("project-default"); | 104 | const defaultProjectId = ProjectId.makeUnsafe("project-default"); |
| 101 | const defaultThreadId = ThreadId.makeUnsafe("thread-default"); | 105 | const defaultThreadId = ThreadId.makeUnsafe("thread-default"); |
| 106 | const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; | ||
| 102 | const defaultModelSelection = { | 107 | const defaultModelSelection = { |
| 103 | provider: "codex", | 108 | provider: "codex", |
| 104 | model: "gpt-5-codex", | 109 | model: "gpt-5-codex", |
| ... | @@ -115,7 +120,6 @@ const testEnvironmentDescriptor = { | ... | @@ -115,7 +120,6 @@ const testEnvironmentDescriptor = { |
| 115 | repositoryIdentity: true, | 120 | repositoryIdentity: true, |
| 116 | }, | 121 | }, |
| 117 | }; | 122 | }; |
| 118 | |||
| 119 | const makeDefaultOrchestrationReadModel = () => { | 123 | const makeDefaultOrchestrationReadModel = () => { |
| 120 | const now = new Date().toISOString(); | 124 | const now = new Date().toISOString(); |
| 121 | return { | 125 | return { |
| ... | @@ -174,6 +178,11 @@ const browserOtlpTracingLayer = Layer.mergeAll( | ... | @@ -174,6 +178,11 @@ const browserOtlpTracingLayer = Layer.mergeAll( |
| 174 | Layer.succeed(HttpClient.TracerDisabledWhen, () => true), | 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 | const makeBrowserOtlpPayload = (spanName: string) => | 186 | const makeBrowserOtlpPayload = (spanName: string) => |
| 178 | Effect.gen(function* () { | 187 | Effect.gen(function* () { |
| 179 | const collector = yield* Effect.acquireRelease( | 188 | const collector = yield* Effect.acquireRelease( |
| ... | @@ -313,7 +322,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -313,7 +322,7 @@ const buildAppUnderTest = (options?: { |
| 313 | otlpMetricsUrl: undefined, | 322 | otlpMetricsUrl: undefined, |
| 314 | otlpExportIntervalMs: 10_000, | 323 | otlpExportIntervalMs: 10_000, |
| 315 | otlpServiceName: "t3-server", | 324 | otlpServiceName: "t3-server", |
| 316 | mode: "web", | 325 | mode: "desktop", |
| 317 | port: 0, | 326 | port: 0, |
| 318 | host: "127.0.0.1", | 327 | host: "127.0.0.1", |
| 319 | cwd: process.cwd(), | 328 | cwd: process.cwd(), |
| ... | @@ -322,7 +331,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -322,7 +331,7 @@ const buildAppUnderTest = (options?: { |
| 322 | staticDir: undefined, | 331 | staticDir: undefined, |
| 323 | devUrl, | 332 | devUrl, |
| 324 | noBrowser: true, | 333 | noBrowser: true, |
| 325 | authToken: undefined, | 334 | desktopBootstrapToken: defaultDesktopBootstrapToken, |
| 326 | autoBootstrapProjectFromCwd: false, | 335 | autoBootstrapProjectFromCwd: false, |
| 327 | logWebSocketEvents: false, | 336 | logWebSocketEvents: false, |
| 328 | ...options?.config, | 337 | ...options?.config, |
| ... | @@ -333,12 +342,16 @@ const buildAppUnderTest = (options?: { | ... | @@ -333,12 +342,16 @@ const buildAppUnderTest = (options?: { |
| 333 | }); | 342 | }); |
| 334 | const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); | 343 | const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); |
| 335 | 344 | ||
| 336 | const appLayer = HttpRouter.serve(makeRoutesLayer, { | 345 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { |
| 337 | disableListenLog: true, | 346 | disableListenLog: true, |
| 338 | disableLogger: true, | 347 | disableLogger: true, |
| 339 | }).pipe( | 348 | }).pipe( |
| 340 | Layer.provide( | 349 | Layer.provide( |
| 341 | Layer.mock(Keybindings)({ | 350 | Layer.mock(Keybindings)({ |
| 351 | loadConfigState: Effect.succeed({ | ||
| 352 | keybindings: [], | ||
| 353 | issues: [], | ||
| 354 | }), | ||
| 342 | streamChanges: Stream.empty, | 355 | streamChanges: Stream.empty, |
| 343 | ...options?.layers?.keybindings, | 356 | ...options?.layers?.keybindings, |
| 344 | }), | 357 | }), |
| ... | @@ -418,6 +431,9 @@ const buildAppUnderTest = (options?: { | ... | @@ -418,6 +431,9 @@ const buildAppUnderTest = (options?: { |
| 418 | ...options?.layers?.checkpointDiffQuery, | 431 | ...options?.layers?.checkpointDiffQuery, |
| 419 | }), | 432 | }), |
| 420 | ), | 433 | ), |
| 434 | ); | ||
| 435 | |||
| 436 | const appLayer = servedRoutesLayer.pipe( | ||
| 421 | Layer.provide( | 437 | Layer.provide( |
| 422 | Layer.mock(BrowserTraceCollector)({ | 438 | Layer.mock(BrowserTraceCollector)({ |
| 423 | record: () => Effect.void, | 439 | record: () => Effect.void, |
| ... | @@ -453,6 +469,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -453,6 +469,7 @@ const buildAppUnderTest = (options?: { |
| 453 | ...options?.layers?.repositoryIdentityResolver, | 469 | ...options?.layers?.repositoryIdentityResolver, |
| 454 | }), | 470 | }), |
| 455 | ), | 471 | ), |
| 472 | Layer.provideMerge(authTestLayer), | ||
| 456 | Layer.provide(workspaceAndProjectServicesLayer), | 473 | Layer.provide(workspaceAndProjectServicesLayer), |
| 457 | Layer.provideMerge(FetchHttpClient.layer), | 474 | Layer.provideMerge(FetchHttpClient.layer), |
| 458 | Layer.provide(layerConfig), | 475 | Layer.provide(layerConfig), |
| ... | @@ -462,11 +479,37 @@ const buildAppUnderTest = (options?: { | ... | @@ -462,11 +479,37 @@ const buildAppUnderTest = (options?: { |
| 462 | return config; | 479 | return config; |
| 463 | }); | 480 | }); |
| 464 | 481 | ||
| 465 | const wsRpcProtocolLayer = (wsUrl: string) => | 482 | const parseSessionCookieFromWsUrl = ( |
| 466 | RpcClient.layerProtocolSocket().pipe( | 483 | wsUrl: string, |
| 467 | Layer.provide(NodeSocket.layerWebSocket(wsUrl)), | 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 | Layer.provide(RpcSerialization.layerJson), | 510 | Layer.provide(RpcSerialization.layerJson), |
| 469 | ); | 511 | ); |
| 512 | }; | ||
| 470 | 513 | ||
| 471 | const makeWsRpcClient = RpcClient.make(WsRpcGroup); | 514 | const makeWsRpcClient = RpcClient.make(WsRpcGroup); |
| 472 | type WsRpcClient = | 515 | type WsRpcClient = |
| ... | @@ -477,6 +520,13 @@ const withWsRpcClient = <A, E, R>( | ... | @@ -477,6 +520,13 @@ const withWsRpcClient = <A, E, R>( |
| 477 | f: (client: WsRpcClient) => Effect.Effect<A, E, R>, | 520 | f: (client: WsRpcClient) => Effect.Effect<A, E, R>, |
| 478 | ) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); | 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 | const getHttpServerUrl = (pathname = "") => | 530 | const getHttpServerUrl = (pathname = "") => |
| 481 | Effect.gen(function* () { | 531 | Effect.gen(function* () { |
| 482 | const server = yield* HttpServer.HttpServer; | 532 | const server = yield* HttpServer.HttpServer; |
| ... | @@ -484,11 +534,130 @@ const getHttpServerUrl = (pathname = "") => | ... | @@ -484,11 +534,130 @@ const getHttpServerUrl = (pathname = "") => |
| 484 | return `http://127.0.0.1:${address.port}${pathname}`; | 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 | Effect.gen(function* () { | 650 | Effect.gen(function* () { |
| 489 | const server = yield* HttpServer.HttpServer; | 651 | const server = yield* HttpServer.HttpServer; |
| 490 | const address = server.address as HttpServer.TcpAddress; | 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 | it.layer(NodeServices.layer)("server router seam", (it) => { | 663 | it.layer(NodeServices.layer)("server router seam", (it) => { |
| ... | @@ -514,11 +683,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -514,11 +683,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 514 | config: { devUrl: new URL("http://127.0.0.1:5173") }, | 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 | const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" })); | 687 | const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" })); |
| 519 | 688 | ||
| 520 | assert.equal(response.status, 302); | 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 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 694 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 523 | ); | 695 | ); |
| 524 | 696 | ||
| ... | @@ -540,6 +712,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -540,6 +712,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 540 | 712 | ||
| 541 | const response = yield* HttpClient.get( | 713 | const response = yield* HttpClient.get( |
| 542 | `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, | 714 | `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, |
| 715 | { | ||
| 716 | headers: { | ||
| 717 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 718 | }, | ||
| 719 | }, | ||
| 543 | ); | 720 | ); |
| 544 | 721 | ||
| 545 | assert.equal(response.status, 200); | 722 | assert.equal(response.status, 200); |
| ... | @@ -560,6 +737,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -560,6 +737,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 560 | 737 | ||
| 561 | const response = yield* HttpClient.get( | 738 | const response = yield* HttpClient.get( |
| 562 | `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, | 739 | `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, |
| 740 | { | ||
| 741 | headers: { | ||
| 742 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 743 | }, | ||
| 744 | }, | ||
| 563 | ); | 745 | ); |
| 564 | 746 | ||
| 565 | assert.equal(response.status, 200); | 747 | assert.equal(response.status, 200); |
| ... | @@ -567,6 +749,597 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -567,6 +749,597 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 567 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("serves attachment files from state dir", () => | 1343 | it.effect("serves attachment files from state dir", () => |
| 571 | Effect.gen(function* () { | 1344 | Effect.gen(function* () { |
| 572 | const fileSystem = yield* FileSystem.FileSystem; | 1345 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | @@ -583,7 +1356,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -583,7 +1356,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 583 | yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); | 1356 | yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); |
| 584 | yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); | 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 | assert.equal(response.status, 200); | 1364 | assert.equal(response.status, 200); |
| 588 | assert.equal(yield* response.text, "attachment-ok"); | 1365 | assert.equal(yield* response.text, "attachment-ok"); |
| 589 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1366 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| ... | @@ -606,6 +1383,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -606,6 +1383,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 606 | 1383 | ||
| 607 | const response = yield* HttpClient.get( | 1384 | const response = yield* HttpClient.get( |
| 608 | "/attachments/thread%20folder/message%20folder/file%20name.png", | 1385 | "/attachments/thread%20folder/message%20folder/file%20name.png", |
| 1386 | { | ||
| 1387 | headers: { | ||
| 1388 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 1389 | }, | ||
| 1390 | }, | ||
| 609 | ); | 1391 | ); |
| 610 | assert.equal(response.status, 200); | 1392 | assert.equal(response.status, 200); |
| 611 | assert.equal(yield* response.text, "attachment-encoded-ok"); | 1393 | assert.equal(yield* response.text, "attachment-encoded-ok"); |
| ... | @@ -742,6 +1524,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -742,6 +1524,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 742 | 1524 | ||
| 743 | const response = yield* HttpClient.post("/api/observability/v1/traces", { | 1525 | const response = yield* HttpClient.post("/api/observability/v1/traces", { |
| 744 | headers: { | 1526 | headers: { |
| 1527 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 745 | "content-type": "application/json", | 1528 | "content-type": "application/json", |
| 746 | origin: "http://localhost:5733", | 1529 | origin: "http://localhost:5733", |
| 747 | }, | 1530 | }, |
| ... | @@ -815,8 +1598,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -815,8 +1598,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 815 | 1598 | ||
| 816 | assert.equal(response.status, 204); | 1599 | assert.equal(response.status, 204); |
| 817 | assert.equal(response.headers.get("access-control-allow-origin"), "*"); | 1600 | assert.equal(response.headers.get("access-control-allow-origin"), "*"); |
| 818 | assert.equal(response.headers.get("access-control-allow-methods"), "POST, OPTIONS"); | 1601 | assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [ |
| 819 | assert.equal(response.headers.get("access-control-allow-headers"), "content-type"); | 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 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1612 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 821 | ); | 1613 | ); |
| 822 | 1614 | ||
| ... | @@ -850,6 +1642,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -850,6 +1642,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 850 | 1642 | ||
| 851 | const response = yield* HttpClient.post("/api/observability/v1/traces", { | 1643 | const response = yield* HttpClient.post("/api/observability/v1/traces", { |
| 852 | headers: { | 1644 | headers: { |
| 1645 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 853 | "content-type": "application/json", | 1646 | "content-type": "application/json", |
| 854 | }, | 1647 | }, |
| 855 | body: HttpBody.text(JSON.stringify(payload), "application/json"), | 1648 | body: HttpBody.text(JSON.stringify(payload), "application/json"), |
| ... | @@ -897,6 +1690,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -897,6 +1690,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 897 | 1690 | ||
| 898 | const response = yield* HttpClient.get( | 1691 | const response = yield* HttpClient.get( |
| 899 | "/attachments/missing-11111111-1111-4111-8111-111111111111", | 1692 | "/attachments/missing-11111111-1111-4111-8111-111111111111", |
| 1693 | { | ||
| 1694 | headers: { | ||
| 1695 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 1696 | }, | ||
| 1697 | }, | ||
| 900 | ); | 1698 | ); |
| 901 | assert.equal(response.status, 404); | 1699 | assert.equal(response.status, 404); |
| 902 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1700 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| ... | @@ -938,7 +1736,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -938,7 +1736,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 938 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | Effect.gen(function* () { | 1740 | Effect.gen(function* () { |
| 943 | const fs = yield* FileSystem.FileSystem; | 1741 | const fs = yield* FileSystem.FileSystem; |
| 944 | const path = yield* Path.Path; | 1742 | const path = yield* Path.Path; |
| ... | @@ -948,13 +1746,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -948,13 +1746,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 948 | "export const needle = 1;", | 1746 | "export const needle = 1;", |
| 949 | ); | 1747 | ); |
| 950 | 1748 | ||
| 951 | yield* buildAppUnderTest({ | 1749 | yield* buildAppUnderTest(); |
| 952 | config: { | ||
| 953 | authToken: "secret-token", | ||
| 954 | }, | ||
| 955 | }); | ||
| 956 | 1750 | ||
| 957 | const wsUrl = yield* getWsServerUrl("/ws"); | 1751 | const wsUrl = yield* getWsServerUrl("/ws", { authenticated: false }); |
| 958 | const result = yield* Effect.scoped( | 1752 | const result = yield* Effect.scoped( |
| 959 | withWsRpcClient(wsUrl, (client) => | 1753 | withWsRpcClient(wsUrl, (client) => |
| 960 | client[WS_METHODS.projectsSearchEntries]({ | 1754 | client[WS_METHODS.projectsSearchEntries]({ |
| ... | @@ -970,38 +1764,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -970,38 +1764,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 970 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => | 1767 | it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => |
| 1006 | Effect.gen(function* () { | 1768 | Effect.gen(function* () { |
| 1007 | const providers = [] as const; | 1769 | const providers = [] as const; |
apps/server/src/server.ts+34-1| ... | @@ -6,7 +6,9 @@ import { | ... | @@ -6,7 +6,9 @@ import { |
| 6 | attachmentsRouteLayer, | 6 | attachmentsRouteLayer, |
| 7 | otlpTracesProxyRouteLayer, | 7 | otlpTracesProxyRouteLayer, |
| 8 | projectFaviconRouteLayer, | 8 | projectFaviconRouteLayer, |
| 9 | serverEnvironmentRouteLayer, | ||
| 9 | staticAndDevRouteLayer, | 10 | staticAndDevRouteLayer, |
| 11 | browserApiCorsLayer, | ||
| 10 | } from "./http"; | 12 | } from "./http"; |
| 11 | import { fixPath } from "./os-jank"; | 13 | import { fixPath } from "./os-jank"; |
| 12 | import { websocketRpcRouteLayer } from "./ws"; | 14 | import { websocketRpcRouteLayer } from "./ws"; |
| ... | @@ -51,6 +53,20 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; | ... | @@ -51,6 +53,20 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; |
| 51 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; | 53 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; |
| 52 | import { ObservabilityLive } from "./observability/Layers/Observability"; | 54 | import { ObservabilityLive } from "./observability/Layers/Observability"; |
| 53 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; | 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 | const PtyAdapterLive = Layer.unwrap( | 71 | const PtyAdapterLive = Layer.unwrap( |
| 56 | Effect.gen(function* () { | 72 | Effect.gen(function* () { |
| ... | @@ -188,6 +204,11 @@ const WorkspaceLayerLive = Layer.mergeAll( | ... | @@ -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 | const RuntimeDependenciesLive = ReactorLayerLive.pipe( | 212 | const RuntimeDependenciesLive = ReactorLayerLive.pipe( |
| 192 | // Core Services | 213 | // Core Services |
| 193 | Layer.provideMerge(CheckpointingLayerLive), | 214 | Layer.provideMerge(CheckpointingLayerLive), |
| ... | @@ -203,6 +224,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -203,6 +224,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( |
| 203 | Layer.provideMerge(ProjectFaviconResolverLive), | 224 | Layer.provideMerge(ProjectFaviconResolverLive), |
| 204 | Layer.provideMerge(RepositoryIdentityResolverLive), | 225 | Layer.provideMerge(RepositoryIdentityResolverLive), |
| 205 | Layer.provideMerge(ServerEnvironmentLive), | 226 | Layer.provideMerge(ServerEnvironmentLive), |
| 227 | Layer.provideMerge(AuthLayerLive), | ||
| 206 | 228 | ||
| 207 | // Misc. | 229 | // Misc. |
| 208 | Layer.provideMerge(AnalyticsServiceLayerLive), | 230 | Layer.provideMerge(AnalyticsServiceLayerLive), |
| ... | @@ -215,12 +237,23 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( | ... | @@ -215,12 +237,23 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( |
| 215 | ); | 237 | ); |
| 216 | 238 | ||
| 217 | export const makeRoutesLayer = Layer.mergeAll( | 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 | attachmentsRouteLayer, | 250 | attachmentsRouteLayer, |
| 219 | otlpTracesProxyRouteLayer, | 251 | otlpTracesProxyRouteLayer, |
| 220 | projectFaviconRouteLayer, | 252 | projectFaviconRouteLayer, |
| 253 | serverEnvironmentRouteLayer, | ||
| 221 | staticAndDevRouteLayer, | 254 | staticAndDevRouteLayer, |
| 222 | websocketRpcRouteLayer, | 255 | websocketRpcRouteLayer, |
| 223 | ); | 256 | ).pipe(Layer.provide(browserApiCorsLayer)); |
| 224 | 257 | ||
| 225 | export const makeServerLayer = Layer.unwrap( | 258 | export const makeServerLayer = Layer.unwrap( |
| 226 | Effect.gen(function* () { | 259 | Effect.gen(function* () { |
apps/server/src/serverRuntimeStartup.test.ts+9| ... | @@ -1,14 +1,23 @@ | ... | @@ -1,14 +1,23 @@ |
| 1 | import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; | ||
| 1 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 2 | import { Deferred, Effect, Fiber, Option, Ref } from "effect"; | 3 | import { Deferred, Effect, Fiber, Option, Ref } from "effect"; |
| 3 | 4 | ||
| 4 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | 5 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; |
| 5 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 6 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 6 | import { | 7 | import { |
| 8 | getAutoBootstrapDefaultModelSelection, | ||
| 7 | launchStartupHeartbeat, | 9 | launchStartupHeartbeat, |
| 8 | makeCommandGate, | 10 | makeCommandGate, |
| 9 | ServerRuntimeStartupError, | 11 | ServerRuntimeStartupError, |
| 10 | } from "./serverRuntimeStartup.ts"; | 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 | it.effect("enqueueCommand waits for readiness and then drains queued work", () => | 21 | it.effect("enqueueCommand waits for readiness and then drains queued work", () => |
| 13 | Effect.scoped( | 22 | Effect.scoped( |
| 14 | Effect.gen(function* () { | 23 | Effect.gen(function* () { |
apps/server/src/serverRuntimeStartup.ts+40-21| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | DEFAULT_MODEL_BY_PROVIDER, | ||
| 3 | DEFAULT_PROVIDER_INTERACTION_MODE, | 4 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 4 | type ModelSelection, | 5 | type ModelSelection, |
| 5 | ProjectId, | 6 | ProjectId, |
| ... | @@ -29,6 +30,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents"; | ... | @@ -29,6 +30,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents"; |
| 29 | import { ServerSettingsService } from "./serverSettings"; | 30 | import { ServerSettingsService } from "./serverSettings"; |
| 30 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; | 31 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; |
| 31 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | 32 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 33 | import { ServerAuth } from "./auth/Services/ServerAuth"; | ||
| 32 | 34 | ||
| 33 | const isWildcardHost = (host: string | undefined): boolean => | 35 | const isWildcardHost = (host: string | undefined): boolean => |
| 34 | host === "0.0.0.0" || host === "::" || host === "[::]"; | 36 | host === "0.0.0.0" || host === "::" || host === "[::]"; |
| ... | @@ -149,6 +151,11 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( | ... | @@ -149,6 +151,11 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( |
| 149 | Effect.asVoid, | 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 | const autoBootstrapWelcome = Effect.gen(function* () { | 159 | const autoBootstrapWelcome = Effect.gen(function* () { |
| 153 | const serverConfig = yield* ServerConfig; | 160 | const serverConfig = yield* ServerConfig; |
| 154 | const projectionReadModelQuery = yield* ProjectionSnapshotQuery; | 161 | const projectionReadModelQuery = yield* ProjectionSnapshotQuery; |
| ... | @@ -170,10 +177,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { | ... | @@ -170,10 +177,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { |
| 170 | const createdAt = new Date().toISOString(); | 177 | const createdAt = new Date().toISOString(); |
| 171 | nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); | 178 | nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); |
| 172 | const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; | 179 | const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; |
| 173 | nextProjectDefaultModelSelection = { | 180 | nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection(); |
| 174 | provider: "codex", | ||
| 175 | model: "gpt-5-codex", | ||
| 176 | }; | ||
| 177 | yield* orchestrationEngine.dispatch({ | 181 | yield* orchestrationEngine.dispatch({ |
| 178 | type: "project.create", | 182 | type: "project.create", |
| 179 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | 183 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), |
| ... | @@ -185,10 +189,8 @@ const autoBootstrapWelcome = Effect.gen(function* () { | ... | @@ -185,10 +189,8 @@ const autoBootstrapWelcome = Effect.gen(function* () { |
| 185 | }); | 189 | }); |
| 186 | } else { | 190 | } else { |
| 187 | nextProjectId = existingProject.value.id; | 191 | nextProjectId = existingProject.value.id; |
| 188 | nextProjectDefaultModelSelection = existingProject.value.defaultModelSelection ?? { | 192 | nextProjectDefaultModelSelection = |
| 189 | provider: "codex", | 193 | existingProject.value.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection(); |
| 190 | model: "gpt-5-codex", | ||
| 191 | }; | ||
| 192 | } | 194 | } |
| 193 | 195 | ||
| 194 | const existingThreadId = | 196 | const existingThreadId = |
| ... | @@ -229,28 +231,39 @@ const autoBootstrapWelcome = Effect.gen(function* () { | ... | @@ -229,28 +231,39 @@ const autoBootstrapWelcome = Effect.gen(function* () { |
| 229 | } as const; | 231 | } as const; |
| 230 | }); | 232 | }); |
| 231 | 233 | ||
| 232 | const maybeOpenBrowser = Effect.gen(function* () { | 234 | const resolveStartupBrowserTarget = Effect.gen(function* () { |
| 233 | const serverConfig = yield* ServerConfig; | 235 | const serverConfig = yield* ServerConfig; |
| 234 | if (serverConfig.noBrowser) { | 236 | const serverAuth = yield* ServerAuth; |
| 235 | return; | ||
| 236 | } | ||
| 237 | const { openBrowser } = yield* Open; | ||
| 238 | const localUrl = `http://localhost:${serverConfig.port}`; | 237 | const localUrl = `http://localhost:${serverConfig.port}`; |
| 239 | const bindUrl = | 238 | const bindUrl = |
| 240 | serverConfig.host && !isWildcardHost(serverConfig.host) | 239 | serverConfig.host && !isWildcardHost(serverConfig.host) |
| 241 | ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` | 240 | ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` |
| 242 | : localUrl; | 241 | : localUrl; |
| 243 | const target = serverConfig.devUrl?.toString() ?? bindUrl; | 242 | const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; |
| 244 | 243 | return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( | |
| 245 | yield* openBrowser(target).pipe( | 244 | Effect.flatMap((target) => |
| 246 | Effect.catch(() => | 245 | target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), |
| 247 | Effect.logInfo("browser auto-open unavailable", { | ||
| 248 | hint: `Open ${target} in your browser.`, | ||
| 249 | }), | ||
| 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 | const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>) => | 267 | const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>) => |
| 255 | effect.pipe( | 268 | effect.pipe( |
| 256 | Effect.annotateSpans({ "startup.phase": phase }), | 269 | Effect.annotateSpans({ "startup.phase": phase }), |
| ... | @@ -371,7 +384,13 @@ const makeServerRuntimeStartup = Effect.gen(function* () { | ... | @@ -371,7 +384,13 @@ const makeServerRuntimeStartup = Effect.gen(function* () { |
| 371 | yield* Effect.logDebug("startup phase: recording startup heartbeat"); | 384 | yield* Effect.logDebug("startup phase: recording startup heartbeat"); |
| 372 | yield* launchStartupHeartbeat; | 385 | yield* launchStartupHeartbeat; |
| 373 | yield* Effect.logDebug("startup phase: browser open check"); | 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 | yield* Effect.logDebug("startup phase: complete"); | 394 | yield* Effect.logDebug("startup phase: complete"); |
| 376 | }), | 395 | }), |
| 377 | ); | 396 | ); |
apps/server/src/ws.ts+833-728| ... | @@ -1,5 +1,7 @@ | ... | @@ -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 | import { | 2 | import { |
| 3 | type AuthAccessStreamEvent, | ||
| 4 | AuthSessionId, | ||
| 3 | CommandId, | 5 | CommandId, |
| 4 | EventId, | 6 | EventId, |
| 5 | type OrchestrationCommand, | 7 | type OrchestrationCommand, |
| ... | @@ -20,7 +22,7 @@ import { | ... | @@ -20,7 +22,7 @@ import { |
| 20 | WsRpcGroup, | 22 | WsRpcGroup, |
| 21 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 22 | import { clamp } from "effect/Number"; | 24 | import { clamp } from "effect/Number"; |
| 23 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 25 | import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; |
| 24 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; | 26 | import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 25 | 27 | ||
| 26 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | 28 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; |
| ... | @@ -49,791 +51,894 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat | ... | @@ -49,791 +51,894 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat |
| 49 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; | 51 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; |
| 50 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; | 52 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; |
| 51 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; | 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( | 65 | function toAuthAccessStreamEvent( |
| 54 | Effect.gen(function* () { | 66 | change: BootstrapCredentialChange | SessionCredentialChange, |
| 55 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | 67 | revision: number, |
| 56 | const orchestrationEngine = yield* OrchestrationEngineService; | 68 | currentSessionId: AuthSessionId, |
| 57 | const checkpointDiffQuery = yield* CheckpointDiffQuery; | 69 | ): AuthAccessStreamEvent { |
| 58 | const keybindings = yield* Keybindings; | 70 | switch (change.type) { |
| 59 | const open = yield* Open; | 71 | case "pairingLinkUpserted": |
| 60 | const gitManager = yield* GitManager; | 72 | return { |
| 61 | const git = yield* GitCore; | 73 | version: 1, |
| 62 | const gitStatusBroadcaster = yield* GitStatusBroadcaster; | 74 | revision, |
| 63 | const terminalManager = yield* TerminalManager; | 75 | type: "pairingLinkUpserted", |
| 64 | const providerRegistry = yield* ProviderRegistry; | 76 | payload: change.pairingLink, |
| 65 | const config = yield* ServerConfig; | 77 | }; |
| 66 | const lifecycleEvents = yield* ServerLifecycleEvents; | 78 | case "pairingLinkRemoved": |
| 67 | const serverSettings = yield* ServerSettingsService; | 79 | return { |
| 68 | const startup = yield* ServerRuntimeStartup; | 80 | version: 1, |
| 69 | const workspaceEntries = yield* WorkspaceEntries; | 81 | revision, |
| 70 | const workspaceFileSystem = yield* WorkspaceFileSystem; | 82 | type: "pairingLinkRemoved", |
| 71 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; | 83 | payload: { id: change.id }, |
| 72 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | 84 | }; |
| 73 | const serverEnvironment = yield* ServerEnvironment; | 85 | case "clientUpserted": |
| 74 | const serverCommandId = (tag: string) => | 86 | return { |
| 75 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); | 87 | version: 1, |
| 76 | 88 | revision, | |
| 77 | const appendSetupScriptActivity = (input: { | 89 | type: "clientUpserted", |
| 78 | readonly threadId: ThreadId; | 90 | payload: { |
| 79 | readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; | 91 | ...change.clientSession, |
| 80 | readonly summary: string; | 92 | current: change.clientSession.sessionId === currentSessionId, |
| 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, | ||
| 97 | }, | 93 | }, |
| 98 | createdAt: input.createdAt, | 94 | }; |
| 99 | }); | 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) => | 105 | const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 102 | Schema.is(OrchestrationDispatchCommandError)(cause) | 106 | WsRpcGroup.toLayer( |
| 103 | ? cause | 107 | Effect.gen(function* () { |
| 104 | : new OrchestrationDispatchCommandError({ | 108 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 105 | message: cause instanceof Error ? cause.message : fallbackMessage, | 109 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 106 | cause, | 110 | const checkpointDiffQuery = yield* CheckpointDiffQuery; |
| 107 | }); | 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>) => { | 133 | const loadAuthAccessSnapshot = () => |
| 110 | const error = Cause.squash(cause); | 134 | Effect.all({ |
| 111 | return Schema.is(OrchestrationDispatchCommandError)(error) | 135 | pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie), |
| 112 | ? error | 136 | clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie), |
| 113 | : new OrchestrationDispatchCommandError({ | 137 | }); |
| 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 | } | ||
| 145 | 138 | ||
| 146 | const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); | 139 | const appendSetupScriptActivity = (input: { |
| 147 | return { | 140 | readonly threadId: ThreadId; |
| 148 | ...event, | 141 | readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; |
| 149 | payload: { | 142 | readonly summary: string; |
| 150 | ...event.payload, | 143 | readonly createdAt: string; |
| 151 | repositoryIdentity, | 144 | readonly payload: Record<string, unknown>; |
| 152 | }, | 145 | readonly tone: "info" | "error"; |
| 153 | } satisfies OrchestrationEvent; | 146 | }) => |
| 154 | }); | 147 | orchestrationEngine.dispatch({ |
| 155 | default: | 148 | type: "thread.activity.append", |
| 156 | return Effect.succeed(event); | 149 | commandId: serverCommandId("setup-script-activity"), |
| 157 | } | 150 | threadId: input.threadId, |
| 158 | }; | 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>) => | 163 | const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => |
| 161 | Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); | 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 = ( | 171 | const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause<unknown>) => { |
| 164 | command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>, | 172 | const error = Cause.squash(cause); |
| 165 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => | 173 | return Schema.is(OrchestrationDispatchCommandError)(error) |
| 166 | Effect.gen(function* () { | 174 | ? error |
| 167 | const bootstrap = command.bootstrap; | 175 | : new OrchestrationDispatchCommandError({ |
| 168 | const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; | 176 | message: |
| 169 | let createdThread = false; | 177 | error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", |
| 170 | let targetProjectId = bootstrap?.createThread?.projectId; | 178 | cause, |
| 171 | let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; | 179 | }); |
| 172 | let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; | 180 | }; |
| 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 | }; | ||
| 213 | 181 | ||
| 214 | const recordSetupScriptStarted = (input: { | 182 | const enrichProjectEvent = ( |
| 215 | readonly requestedAt: string; | 183 | event: OrchestrationEvent, |
| 216 | readonly worktreePath: string; | 184 | ): Effect.Effect<OrchestrationEvent, never, never> => { |
| 217 | readonly scriptId: string; | 185 | switch (event.type) { |
| 218 | readonly scriptName: string; | 186 | case "project.created": |
| 219 | readonly terminalId: string; | 187 | return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( |
| 220 | }) => { | 188 | Effect.map((repositoryIdentity) => ({ |
| 221 | const payload = { | 189 | ...event, |
| 222 | scriptId: input.scriptId, | 190 | payload: { |
| 223 | scriptName: input.scriptName, | 191 | ...event.payload, |
| 224 | terminalId: input.terminalId, | 192 | repositoryIdentity, |
| 225 | worktreePath: input.worktreePath, | 193 | }, |
| 226 | }; | 194 | })), |
| 227 | return Effect.all([ | 195 | ); |
| 228 | appendSetupScriptActivity({ | 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 | threadId: command.threadId, | 255 | threadId: command.threadId, |
| 230 | kind: "setup-script.requested", | 256 | kind: "setup-script.failed", |
| 231 | summary: "Starting setup script", | 257 | summary: "Setup script failed to start", |
| 232 | createdAt: input.requestedAt, | 258 | createdAt: input.requestedAt, |
| 233 | payload, | 259 | payload: { |
| 234 | tone: "info", | 260 | detail, |
| 235 | }), | 261 | worktreePath: input.worktreePath, |
| 236 | appendSetupScriptActivity({ | 262 | }, |
| 237 | threadId: command.threadId, | 263 | tone: "error", |
| 238 | kind: "setup-script.started", | 264 | }).pipe( |
| 239 | summary: "Setup script started", | 265 | Effect.ignoreCause({ log: false }), |
| 240 | createdAt: new Date().toISOString(), | 266 | Effect.flatMap(() => |
| 241 | payload, | 267 | Effect.logWarning("bootstrap turn start failed to launch setup script", { |
| 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 | { | ||
| 250 | threadId: command.threadId, | 268 | threadId: command.threadId, |
| 251 | worktreePath: input.worktreePath, | 269 | worktreePath: input.worktreePath, |
| 252 | scriptId: input.scriptId, | 270 | detail, |
| 253 | terminalId: input.terminalId, | 271 | }), |
| 254 | detail: | ||
| 255 | error instanceof Error | ||
| 256 | ? error.message | ||
| 257 | : "Unknown setup activity dispatch failure.", | ||
| 258 | }, | ||
| 259 | ), | 272 | ), |
| 260 | ), | 273 | ); |
| 261 | ); | 274 | }; |
| 262 | }; | ||
| 263 | 275 | ||
| 264 | const runSetupProgram = () => | 276 | const recordSetupScriptStarted = (input: { |
| 265 | bootstrap?.runSetupScript && targetWorktreePath | 277 | readonly requestedAt: string; |
| 266 | ? (() => { | 278 | readonly worktreePath: string; |
| 267 | const worktreePath = targetWorktreePath; | 279 | readonly scriptId: string; |
| 268 | const requestedAt = new Date().toISOString(); | 280 | readonly scriptName: string; |
| 269 | return projectSetupScriptRunner | 281 | readonly terminalId: string; |
| 270 | .runForThread({ | 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 | threadId: command.threadId, | 312 | threadId: command.threadId, |
| 272 | ...(targetProjectId ? { projectId: targetProjectId } : {}), | 313 | worktreePath: input.worktreePath, |
| 273 | ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), | 314 | scriptId: input.scriptId, |
| 274 | worktreePath, | 315 | terminalId: input.terminalId, |
| 275 | }) | 316 | detail: |
| 276 | .pipe( | 317 | error instanceof Error |
| 277 | Effect.matchEffect({ | 318 | ? error.message |
| 278 | onFailure: (error) => | 319 | : "Unknown setup activity dispatch failure.", |
| 279 | recordSetupScriptLaunchFailure({ | 320 | }, |
| 280 | error, | 321 | ), |
| 281 | requestedAt, | 322 | ), |
| 282 | worktreePath, | 323 | ); |
| 283 | }), | 324 | }; |
| 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 | } | ||
| 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); | 363 | const bootstrapProgram = Effect.gen(function* () { |
| 339 | }); | 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( | 381 | if (bootstrap?.prepareWorktree) { |
| 342 | Effect.catchCause((cause) => { | 382 | const worktree = yield* git.createWorktree({ |
| 343 | const dispatchError = toBootstrapDispatchCommandCauseError(cause); | 383 | cwd: bootstrap.prepareWorktree.projectCwd, |
| 344 | if (Cause.hasInterruptsOnly(cause)) { | 384 | branch: bootstrap.prepareWorktree.baseBranch, |
| 345 | return Effect.fail(dispatchError); | 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 = ( | 398 | yield* runSetupProgram(); |
| 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 | ); | ||
| 365 | 399 | ||
| 366 | return startup | 400 | return yield* orchestrationEngine.dispatch(finalTurnStartCommand); |
| 367 | .enqueueCommand(dispatchEffect) | 401 | }); |
| 368 | .pipe( | ||
| 369 | Effect.mapError((cause) => | ||
| 370 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), | ||
| 371 | ), | ||
| 372 | ); | ||
| 373 | }; | ||
| 374 | 402 | ||
| 375 | const loadServerConfig = Effect.gen(function* () { | 403 | return yield* bootstrapProgram.pipe( |
| 376 | const keybindingsConfig = yield* keybindings.loadConfigState; | 404 | Effect.catchCause((cause) => { |
| 377 | const providers = yield* providerRegistry.getProviders; | 405 | const dispatchError = toBootstrapDispatchCommandCauseError(cause); |
| 378 | const settings = yield* serverSettings.getSettings; | 406 | if (Cause.hasInterruptsOnly(cause)) { |
| 379 | const environment = yield* serverEnvironment.getDescriptor; | 407 | return Effect.fail(dispatchError); |
| 408 | } | ||
| 409 | return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); | ||
| 410 | }), | ||
| 411 | ); | ||
| 412 | }); | ||
| 380 | 413 | ||
| 381 | return { | 414 | const dispatchNormalizedCommand = ( |
| 382 | environment, | 415 | normalizedCommand: OrchestrationCommand, |
| 383 | cwd: config.cwd, | 416 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { |
| 384 | keybindingsConfigPath: config.keybindingsConfigPath, | 417 | const dispatchEffect = |
| 385 | keybindings: keybindingsConfig.keybindings, | 418 | normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap |
| 386 | issues: keybindingsConfig.issues, | 419 | ? dispatchBootstrapTurnStart(normalizedCommand) |
| 387 | providers, | 420 | : orchestrationEngine |
| 388 | availableEditors: resolveAvailableEditors(), | 421 | .dispatch(normalizedCommand) |
| 389 | observability: { | 422 | .pipe( |
| 390 | logsDirectoryPath: config.logsDir, | 423 | Effect.mapError((cause) => |
| 391 | localTracingEnabled: true, | 424 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), |
| 392 | ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), | 425 | ), |
| 393 | otlpTracesEnabled: config.otlpTracesUrl !== undefined, | 426 | ); |
| 394 | ...(config.otlpMetricsUrl !== undefined ? { otlpMetricsUrl: config.otlpMetricsUrl } : {}), | 427 | |
| 395 | otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, | 428 | return startup |
| 396 | }, | 429 | .enqueueCommand(dispatchEffect) |
| 397 | settings, | 430 | .pipe( |
| 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( | ||
| 439 | Effect.mapError((cause) => | 431 | Effect.mapError((cause) => |
| 440 | Schema.is(OrchestrationDispatchCommandError)(cause) | 432 | toDispatchCommandError(cause, "Failed to dispatch orchestration command"), |
| 441 | ? cause | 433 | ), |
| 442 | : new OrchestrationDispatchCommandError({ | 434 | ); |
| 443 | message: "Failed to dispatch orchestration command", | 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 | cause, | 481 | cause, |
| 445 | }), | 482 | }), |
| 483 | ), | ||
| 446 | ), | 484 | ), |
| 485 | { "rpc.aggregate": "orchestration" }, | ||
| 447 | ), | 486 | ), |
| 448 | { "rpc.aggregate": "orchestration" }, | 487 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => |
| 449 | ), | 488 | observeRpcEffect( |
| 450 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => | 489 | ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 451 | observeRpcEffect( | 490 | Effect.gen(function* () { |
| 452 | ORCHESTRATION_WS_METHODS.getTurnDiff, | 491 | const normalizedCommand = yield* normalizeDispatchCommand(command); |
| 453 | checkpointDiffQuery.getTurnDiff(input).pipe( | 492 | const result = yield* dispatchNormalizedCommand(normalizedCommand); |
| 454 | Effect.mapError( | 493 | if (normalizedCommand.type === "thread.archive") { |
| 455 | (cause) => | 494 | yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( |
| 456 | new OrchestrationGetTurnDiffError({ | 495 | Effect.catch((error) => |
| 457 | message: "Failed to load turn diff", | 496 | Effect.logWarning("failed to close thread terminals after archive", { |
| 458 | cause, | 497 | threadId: normalizedCommand.threadId, |
| 459 | }), | 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" }, | 516 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => |
| 463 | ), | 517 | observeRpcEffect( |
| 464 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => | 518 | ORCHESTRATION_WS_METHODS.getTurnDiff, |
| 465 | observeRpcEffect( | 519 | checkpointDiffQuery.getTurnDiff(input).pipe( |
| 466 | ORCHESTRATION_WS_METHODS.getFullThreadDiff, | 520 | Effect.mapError( |
| 467 | checkpointDiffQuery.getFullThreadDiff(input).pipe( | 521 | (cause) => |
| 468 | Effect.mapError( | 522 | new OrchestrationGetTurnDiffError({ |
| 469 | (cause) => | 523 | message: "Failed to load turn diff", |
| 470 | new OrchestrationGetFullThreadDiffError({ | 524 | cause, |
| 471 | message: "Failed to load full thread diff", | 525 | }), |
| 472 | cause, | 526 | ), |
| 473 | }), | ||
| 474 | ), | 527 | ), |
| 528 | { "rpc.aggregate": "orchestration" }, | ||
| 475 | ), | 529 | ), |
| 476 | { "rpc.aggregate": "orchestration" }, | 530 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => |
| 477 | ), | 531 | observeRpcEffect( |
| 478 | [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => | 532 | ORCHESTRATION_WS_METHODS.getFullThreadDiff, |
| 479 | observeRpcEffect( | 533 | checkpointDiffQuery.getFullThreadDiff(input).pipe( |
| 480 | ORCHESTRATION_WS_METHODS.replayEvents, | 534 | Effect.mapError( |
| 481 | Stream.runCollect( | 535 | (cause) => |
| 482 | orchestrationEngine.readEvents( | 536 | new OrchestrationGetFullThreadDiffError({ |
| 483 | clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), | 537 | message: "Failed to load full thread diff", |
| 484 | ), | 538 | cause, |
| 485 | ).pipe( | 539 | }), |
| 486 | Effect.map((events) => Array.from(events)), | 540 | ), |
| 487 | Effect.flatMap(enrichOrchestrationEvents), | ||
| 488 | Effect.mapError( | ||
| 489 | (cause) => | ||
| 490 | new OrchestrationReplayEventsError({ | ||
| 491 | message: "Failed to replay orchestration events", | ||
| 492 | cause, | ||
| 493 | }), | ||
| 494 | ), | 541 | ), |
| 542 | { "rpc.aggregate": "orchestration" }, | ||
| 495 | ), | 543 | ), |
| 496 | { "rpc.aggregate": "orchestration" }, | 544 | [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => |
| 497 | ), | 545 | observeRpcEffect( |
| 498 | [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => | 546 | ORCHESTRATION_WS_METHODS.replayEvents, |
| 499 | observeRpcStreamEffect( | 547 | Stream.runCollect( |
| 500 | WS_METHODS.subscribeOrchestrationDomainEvents, | 548 | orchestrationEngine.readEvents( |
| 501 | Effect.gen(function* () { | 549 | clamp(input.fromSequenceExclusive, { |
| 502 | const snapshot = yield* orchestrationEngine.getReadModel(); | 550 | maximum: Number.MAX_SAFE_INTEGER, |
| 503 | const fromSequenceExclusive = snapshot.snapshotSequence; | 551 | minimum: 0, |
| 504 | const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect( | 552 | }), |
| 505 | orchestrationEngine.readEvents(fromSequenceExclusive), | 553 | ), |
| 506 | ).pipe( | 554 | ).pipe( |
| 507 | Effect.map((events) => Array.from(events)), | 555 | Effect.map((events) => Array.from(events)), |
| 508 | Effect.flatMap(enrichOrchestrationEvents), | 556 | Effect.flatMap(enrichOrchestrationEvents), |
| 509 | Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)), | 557 | Effect.mapError( |
| 510 | ); | 558 | (cause) => |
| 511 | const replayStream = Stream.fromIterable(replayEvents); | 559 | new OrchestrationReplayEventsError({ |
| 512 | const liveStream = orchestrationEngine.streamDomainEvents.pipe( | 560 | message: "Failed to replay orchestration events", |
| 513 | Stream.mapEffect(enrichProjectEvent), | 561 | cause, |
| 514 | ); | 562 | }), |
| 515 | const source = Stream.merge(replayStream, liveStream); | 563 | ), |
| 516 | type SequenceState = { | 564 | ), |
| 517 | readonly nextSequence: number; | 565 | { "rpc.aggregate": "orchestration" }, |
| 518 | readonly pendingBySequence: Map<number, OrchestrationEvent>; | 566 | ), |
| 519 | }; | 567 | [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => |
| 520 | const state = yield* Ref.make<SequenceState>({ | 568 | observeRpcStreamEffect( |
| 521 | nextSequence: fromSequenceExclusive + 1, | 569 | WS_METHODS.subscribeOrchestrationDomainEvents, |
| 522 | pendingBySequence: new Map<number, OrchestrationEvent>(), | 570 | Effect.gen(function* () { |
| 523 | }); | 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( | 594 | return source.pipe( |
| 526 | Stream.mapEffect((event) => | 595 | Stream.mapEffect((event) => |
| 527 | Ref.modify( | 596 | Ref.modify( |
| 528 | state, | 597 | state, |
| 529 | ({ | 598 | ({ |
| 530 | nextSequence, | 599 | nextSequence, |
| 531 | pendingBySequence, | 600 | pendingBySequence, |
| 532 | }): [Array<OrchestrationEvent>, SequenceState] => { | 601 | }): [Array<OrchestrationEvent>, SequenceState] => { |
| 533 | if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { | 602 | if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { |
| 534 | return [[], { nextSequence, pendingBySequence }]; | 603 | 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; | ||
| 546 | } | 604 | } |
| 547 | emit.push(expectedEvent); | ||
| 548 | updatedPending.delete(expected); | ||
| 549 | expected += 1; | ||
| 550 | } | ||
| 551 | 605 | ||
| 552 | return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; | 606 | const updatedPending = new Map(pendingBySequence); |
| 553 | }, | 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 | ), | 625 | Stream.flatMap((events) => Stream.fromIterable(events)), |
| 556 | Stream.flatMap((events) => Stream.fromIterable(events)), | 626 | ); |
| 557 | ); | 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" }, | 634 | [WS_METHODS.serverRefreshProviders]: (_input) => |
| 560 | ), | 635 | observeRpcEffect( |
| 561 | [WS_METHODS.serverGetConfig]: (_input) => | 636 | WS_METHODS.serverRefreshProviders, |
| 562 | observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { | 637 | providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), |
| 563 | "rpc.aggregate": "server", | 638 | { "rpc.aggregate": "server" }, |
| 564 | }), | 639 | ), |
| 565 | [WS_METHODS.serverRefreshProviders]: (_input) => | 640 | [WS_METHODS.serverUpsertKeybinding]: (rule) => |
| 566 | observeRpcEffect( | 641 | observeRpcEffect( |
| 567 | WS_METHODS.serverRefreshProviders, | 642 | WS_METHODS.serverUpsertKeybinding, |
| 568 | providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), | 643 | Effect.gen(function* () { |
| 569 | { "rpc.aggregate": "server" }, | 644 | const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); |
| 570 | ), | 645 | return { keybindings: keybindingsConfig, issues: [] }; |
| 571 | [WS_METHODS.serverUpsertKeybinding]: (rule) => | 646 | }), |
| 572 | observeRpcEffect( | 647 | { "rpc.aggregate": "server" }, |
| 573 | WS_METHODS.serverUpsertKeybinding, | 648 | ), |
| 574 | Effect.gen(function* () { | 649 | [WS_METHODS.serverGetSettings]: (_input) => |
| 575 | const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); | 650 | observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { |
| 576 | return { keybindings: keybindingsConfig, issues: [] }; | 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" }, | 657 | [WS_METHODS.projectsSearchEntries]: (input) => |
| 579 | ), | 658 | observeRpcEffect( |
| 580 | [WS_METHODS.serverGetSettings]: (_input) => | 659 | WS_METHODS.projectsSearchEntries, |
| 581 | observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { | 660 | workspaceEntries.search(input).pipe( |
| 582 | "rpc.aggregate": "server", | 661 | Effect.mapError( |
| 583 | }), | 662 | (cause) => |
| 584 | [WS_METHODS.serverUpdateSettings]: ({ patch }) => | 663 | new ProjectSearchEntriesError({ |
| 585 | observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { | 664 | message: `Failed to search workspace entries: ${cause.detail}`, |
| 586 | "rpc.aggregate": "server", | 665 | cause, |
| 587 | }), | 666 | }), |
| 588 | [WS_METHODS.projectsSearchEntries]: (input) => | 667 | ), |
| 589 | observeRpcEffect( | 668 | ), |
| 590 | WS_METHODS.projectsSearchEntries, | 669 | { "rpc.aggregate": "workspace" }, |
| 591 | workspaceEntries.search(input).pipe( | 670 | ), |
| 592 | Effect.mapError( | 671 | [WS_METHODS.projectsWriteFile]: (input) => |
| 593 | (cause) => | 672 | observeRpcEffect( |
| 594 | new ProjectSearchEntriesError({ | 673 | WS_METHODS.projectsWriteFile, |
| 595 | message: `Failed to search workspace entries: ${cause.detail}`, | 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 | cause, | 681 | cause, |
| 597 | }), | 682 | }); |
| 683 | }), | ||
| 598 | ), | 684 | ), |
| 685 | { "rpc.aggregate": "workspace" }, | ||
| 599 | ), | 686 | ), |
| 600 | { "rpc.aggregate": "workspace" }, | 687 | [WS_METHODS.shellOpenInEditor]: (input) => |
| 601 | ), | 688 | observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { |
| 602 | [WS_METHODS.projectsWriteFile]: (input) => | 689 | "rpc.aggregate": "workspace", |
| 603 | observeRpcEffect( | 690 | }), |
| 604 | WS_METHODS.projectsWriteFile, | 691 | [WS_METHODS.subscribeGitStatus]: (input) => |
| 605 | workspaceFileSystem.writeFile(input).pipe( | 692 | observeRpcStream( |
| 606 | Effect.mapError((cause) => { | 693 | WS_METHODS.subscribeGitStatus, |
| 607 | const message = Schema.is(WorkspacePathOutsideRootError)(cause) | 694 | gitStatusBroadcaster.streamStatus(input), |
| 608 | ? "Workspace file path must stay within the project root." | 695 | { |
| 609 | : "Failed to write workspace file"; | 696 | "rpc.aggregate": "git", |
| 610 | return new ProjectWriteFileError({ | 697 | }, |
| 611 | message, | ||
| 612 | cause, | ||
| 613 | }); | ||
| 614 | }), | ||
| 615 | ), | 698 | ), |
| 616 | { "rpc.aggregate": "workspace" }, | 699 | [WS_METHODS.gitRefreshStatus]: (input) => |
| 617 | ), | 700 | observeRpcEffect( |
| 618 | [WS_METHODS.shellOpenInEditor]: (input) => | 701 | WS_METHODS.gitRefreshStatus, |
| 619 | observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { | 702 | gitStatusBroadcaster.refreshStatus(input.cwd), |
| 620 | "rpc.aggregate": "workspace", | 703 | { |
| 621 | }), | 704 | "rpc.aggregate": "git", |
| 622 | [WS_METHODS.subscribeGitStatus]: (input) => | 705 | }, |
| 623 | observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), { | 706 | ), |
| 624 | "rpc.aggregate": "git", | 707 | [WS_METHODS.gitPull]: (input) => |
| 625 | }), | 708 | observeRpcEffect( |
| 626 | [WS_METHODS.gitRefreshStatus]: (input) => | 709 | WS_METHODS.gitPull, |
| 627 | observeRpcEffect( | 710 | git.pullCurrentBranch(input.cwd).pipe( |
| 628 | WS_METHODS.gitRefreshStatus, | 711 | Effect.matchCauseEffect({ |
| 629 | gitStatusBroadcaster.refreshStatus(input.cwd), | 712 | onFailure: (cause) => Effect.failCause(cause), |
| 630 | { | 713 | onSuccess: (result) => |
| 631 | "rpc.aggregate": "git", | 714 | refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), |
| 632 | }, | 715 | }), |
| 633 | ), | 716 | ), |
| 634 | [WS_METHODS.gitPull]: (input) => | 717 | { "rpc.aggregate": "git" }, |
| 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 | }), | ||
| 643 | ), | 718 | ), |
| 644 | { "rpc.aggregate": "git" }, | 719 | [WS_METHODS.gitRunStackedAction]: (input) => |
| 645 | ), | 720 | observeRpcStream( |
| 646 | [WS_METHODS.gitRunStackedAction]: (input) => | 721 | WS_METHODS.gitRunStackedAction, |
| 647 | observeRpcStream( | 722 | Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) => |
| 648 | WS_METHODS.gitRunStackedAction, | 723 | gitManager |
| 649 | Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) => | 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 | gitManager | 749 | gitManager |
| 651 | .runStackedAction(input, { | 750 | .preparePullRequestThread(input) |
| 652 | actionId: input.actionId, | 751 | .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 653 | progressReporter: { | 752 | { "rpc.aggregate": "git" }, |
| 654 | publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), | 753 | ), |
| 655 | }, | 754 | [WS_METHODS.gitListBranches]: (input) => |
| 656 | }) | 755 | observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { |
| 657 | .pipe( | 756 | "rpc.aggregate": "git", |
| 658 | Effect.matchCauseEffect({ | 757 | }), |
| 659 | onFailure: (cause) => Queue.failCause(queue, cause), | 758 | [WS_METHODS.gitCreateWorktree]: (input) => |
| 660 | onSuccess: () => | 759 | observeRpcEffect( |
| 661 | refreshGitStatus(input.cwd).pipe( | 760 | WS_METHODS.gitCreateWorktree, |
| 662 | Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), | 761 | git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 663 | ), | 762 | { "rpc.aggregate": "git" }, |
| 664 | }), | 763 | ), |
| 665 | ), | 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" }, | 770 | [WS_METHODS.gitCreateBranch]: (input) => |
| 668 | ), | 771 | observeRpcEffect( |
| 669 | [WS_METHODS.gitResolvePullRequest]: (input) => | 772 | WS_METHODS.gitCreateBranch, |
| 670 | observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { | 773 | git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 671 | "rpc.aggregate": "git", | 774 | { "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)), | ||
| 708 | ), | 775 | ), |
| 709 | { "rpc.aggregate": "git" }, | 776 | [WS_METHODS.gitCheckout]: (input) => |
| 710 | ), | 777 | observeRpcEffect( |
| 711 | [WS_METHODS.gitInit]: (input) => | 778 | WS_METHODS.gitCheckout, |
| 712 | observeRpcEffect( | 779 | Effect.scoped(git.checkoutBranch(input)).pipe( |
| 713 | WS_METHODS.gitInit, | 780 | Effect.tap(() => refreshGitStatus(input.cwd)), |
| 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), | ||
| 748 | ), | 781 | ), |
| 782 | { "rpc.aggregate": "git" }, | ||
| 749 | ), | 783 | ), |
| 750 | { "rpc.aggregate": "terminal" }, | 784 | [WS_METHODS.gitInit]: (input) => |
| 751 | ), | 785 | observeRpcEffect( |
| 752 | [WS_METHODS.subscribeServerConfig]: (_input) => | 786 | WS_METHODS.gitInit, |
| 753 | observeRpcStreamEffect( | 787 | git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 754 | WS_METHODS.subscribeServerConfig, | 788 | { "rpc.aggregate": "git" }, |
| 755 | Effect.gen(function* () { | 789 | ), |
| 756 | const keybindingsUpdates = keybindings.streamChanges.pipe( | 790 | [WS_METHODS.terminalOpen]: (input) => |
| 757 | Stream.map((event) => ({ | 791 | observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { |
| 758 | version: 1 as const, | 792 | "rpc.aggregate": "terminal", |
| 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 | ); | ||
| 788 | }), | 793 | }), |
| 789 | { "rpc.aggregate": "server" }, | 794 | [WS_METHODS.terminalWrite]: (input) => |
| 790 | ), | 795 | observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { |
| 791 | [WS_METHODS.subscribeServerLifecycle]: (_input) => | 796 | "rpc.aggregate": "terminal", |
| 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); | ||
| 803 | }), | 797 | }), |
| 804 | { "rpc.aggregate": "server" }, | 798 | [WS_METHODS.terminalResize]: (input) => |
| 805 | ), | 799 | observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { |
| 806 | }); | 800 | "rpc.aggregate": "terminal", |
| 807 | }), | 801 | }), |
| 808 | ); | 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 | export const websocketRpcRouteLayer = Layer.unwrap( | 915 | export const websocketRpcRouteLayer = Layer.unwrap( |
| 811 | Effect.gen(function* () { | 916 | Effect.succeed( |
| 812 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { | 917 | HttpRouter.add( |
| 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( | ||
| 820 | "GET", | 918 | "GET", |
| 821 | "/ws", | 919 | "/ws", |
| 822 | Effect.gen(function* () { | 920 | Effect.gen(function* () { |
| 823 | const request = yield* HttpServerRequest.HttpServerRequest; | 921 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 824 | const config = yield* ServerConfig; | 922 | const serverAuth = yield* ServerAuth; |
| 825 | if (config.authToken) { | 923 | const sessions = yield* SessionCredentialService; |
| 826 | const url = HttpServerRequest.toURL(request); | 924 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request); |
| 827 | if (Option.isNone(url)) { | 925 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { |
| 828 | return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 }); | 926 | spanPrefix: "ws.rpc", |
| 829 | } | 927 | spanAttributes: { |
| 830 | const token = url.value.searchParams.get("token"); | 928 | "rpc.transport": "websocket", |
| 831 | if (token !== config.authToken) { | 929 | "rpc.system": "effect-rpc", |
| 832 | return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 }); | 930 | }, |
| 833 | } | 931 | }).pipe( |
| 834 | } | 932 | Effect.provide( |
| 835 | return yield* rpcWebSocketHttpEffect; | 933 | makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)), |
| 836 | }), | 934 | ), |
| 837 | ); | 935 | ); |
| 838 | }), | 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,9 +2,84 @@ |
| 2 | <html lang="en"> | 2 | <html lang="en"> |
| 3 | <head> | 3 | <head> |
| 4 | <meta charset="UTF-8" /> | 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 | <link rel="icon" href="/favicon.ico" sizes="48x48" /> | 9 | <link rel="icon" href="/favicon.ico" sizes="48x48" /> |
| 7 | <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> | 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 | <link rel="preconnect" href="https://fonts.googleapis.com" /> | 83 | <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| 9 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | 84 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| 10 | <link | 85 | <link |
| ... | @@ -12,10 +87,15 @@ | ... | @@ -12,10 +87,15 @@ |
| 12 | rel="stylesheet" | 87 | rel="stylesheet" |
| 13 | /> | 88 | /> |
| 14 | <title>T3 Code (Alpha)</title> | 89 | <title>T3 Code (Alpha)</title> |
| 15 | <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script> | ||
| 16 | </head> | 90 | </head> |
| 17 | <body> | 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 | <script type="module" src="/src/main.tsx"></script> | 99 | <script type="module" src="/src/main.tsx"></script> |
| 20 | </body> | 100 | </body> |
| 21 | </html> | 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,10 +1,17 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, GitBranch, ProjectId } from "@t3tools/contracts"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | export { | 3 | export { |
| 4 | dedupeRemoteBranchesWithLocalMatches, | 4 | dedupeRemoteBranchesWithLocalMatches, |
| 5 | deriveLocalBranchNameFromRemoteRef, | 5 | deriveLocalBranchNameFromRemoteRef, |
| 6 | } from "@t3tools/shared/git"; | 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 | export const EnvMode = Schema.Literals(["local", "worktree"]); | 15 | export const EnvMode = Schema.Literals(["local", "worktree"]); |
| 9 | export type EnvMode = typeof EnvMode.Type; | 16 | export type EnvMode = typeof EnvMode.Type; |
| 10 | 17 |
apps/web/src/components/BranchToolbar.tsx+37-127| ... | @@ -1,25 +1,19 @@ | ... | @@ -1,25 +1,19 @@ |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { FolderIcon, GitForkIcon } from "lucide-react"; | 3 | import { useMemo } from "react"; |
| 4 | import { useCallback, useMemo } from "react"; | ||
| 5 | 4 | ||
| 6 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | 5 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 7 | import { readEnvironmentApi } from "../environmentApi"; | ||
| 8 | import { newCommandId } from "../lib/utils"; | ||
| 9 | import { useStore } from "../store"; | 6 | import { useStore } from "../store"; |
| 10 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | 7 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; |
| 11 | import { | 8 | import { |
| 12 | EnvMode, | 9 | type EnvMode, |
| 13 | resolveDraftEnvModeAfterBranchChange, | 10 | type EnvironmentOption, |
| 14 | resolveEffectiveEnvMode, | 11 | resolveEffectiveEnvMode, |
| 15 | } from "./BranchToolbar.logic"; | 12 | } from "./BranchToolbar.logic"; |
| 16 | import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; | 13 | import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; |
| 17 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | 14 | import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; |
| 18 | 15 | import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; | |
| 19 | const envModeItems = [ | 16 | import { Separator } from "./ui/separator"; |
| 20 | { value: "local", label: "Local" }, | ||
| 21 | { value: "worktree", label: "New worktree" }, | ||
| 22 | ] as const; | ||
| 23 | 17 | ||
| 24 | interface BranchToolbarProps { | 18 | interface BranchToolbarProps { |
| 25 | environmentId: EnvironmentId; | 19 | environmentId: EnvironmentId; |
| ... | @@ -29,9 +23,11 @@ interface BranchToolbarProps { | ... | @@ -29,9 +23,11 @@ interface BranchToolbarProps { |
| 29 | envLocked: boolean; | 23 | envLocked: boolean; |
| 30 | onCheckoutPullRequestRequest?: (reference: string) => void; | 24 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| 31 | onComposerFocusRequest?: () => void; | 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 | environmentId, | 31 | environmentId, |
| 36 | threadId, | 32 | threadId, |
| 37 | draftId, | 33 | draftId, |
| ... | @@ -39,6 +35,8 @@ export default function BranchToolbar({ | ... | @@ -39,6 +35,8 @@ export default function BranchToolbar({ |
| 39 | envLocked, | 35 | envLocked, |
| 40 | onCheckoutPullRequestRequest, | 36 | onCheckoutPullRequestRequest, |
| 41 | onComposerFocusRequest, | 37 | onComposerFocusRequest, |
| 38 | availableEnvironments, | ||
| 39 | onEnvironmentChange, | ||
| 42 | }: BranchToolbarProps) { | 40 | }: BranchToolbarProps) { |
| 43 | const threadRef = useMemo( | 41 | const threadRef = useMemo( |
| 44 | () => scopeThreadRef(environmentId, threadId), | 42 | () => scopeThreadRef(environmentId, threadId), |
| ... | @@ -46,10 +44,7 @@ export default function BranchToolbar({ | ... | @@ -46,10 +44,7 @@ export default function BranchToolbar({ |
| 46 | ); | 44 | ); |
| 47 | const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); | 45 | const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); |
| 48 | const serverThread = useStore(serverThreadSelector); | 46 | const serverThread = useStore(serverThreadSelector); |
| 49 | const serverSession = serverThread?.session ?? null; | ||
| 50 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); | ||
| 51 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); | 47 | const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); |
| 52 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | ||
| 53 | const activeProjectRef = serverThread | 48 | const activeProjectRef = serverThread |
| 54 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) | 49 | ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) |
| 55 | : draftThread | 50 | : draftThread |
| ... | @@ -60,131 +55,46 @@ export default function BranchToolbar({ | ... | @@ -60,131 +55,46 @@ export default function BranchToolbar({ |
| 60 | [activeProjectRef], | 55 | [activeProjectRef], |
| 61 | ); | 56 | ); |
| 62 | const activeProject = useStore(activeProjectSelector); | 57 | const activeProject = useStore(activeProjectSelector); |
| 63 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); | 58 | const hasActiveThread = serverThread !== undefined || draftThread !== null; |
| 64 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; | ||
| 65 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | 59 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| 66 | const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null; | ||
| 67 | const hasServerThread = serverThread !== undefined; | ||
| 68 | const effectiveEnvMode = resolveEffectiveEnvMode({ | 60 | const effectiveEnvMode = resolveEffectiveEnvMode({ |
| 69 | activeWorktreePath, | 61 | activeWorktreePath, |
| 70 | hasServerThread, | 62 | hasServerThread: serverThread !== undefined, |
| 71 | draftThreadEnvMode: draftThread?.envMode, | 63 | draftThreadEnvMode: draftThread?.envMode, |
| 72 | }); | 64 | }); |
| 73 | 65 | ||
| 74 | const setThreadBranch = useCallback( | 66 | const showEnvironmentPicker = |
| 75 | (branch: string | null, worktreePath: string | null) => { | 67 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange; |
| 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 | ); | ||
| 129 | 68 | ||
| 130 | if (!activeThreadId || !activeProject) return null; | 69 | if (!hasActiveThread || !activeProject) return null; |
| 131 | 70 | ||
| 132 | return ( | 71 | return ( |
| 133 | <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1"> | 72 | <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1"> |
| 134 | {envLocked || activeWorktreePath ? ( | 73 | <div className="flex items-center gap-1"> |
| 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"> | 74 | {showEnvironmentPicker && ( |
| 136 | {activeWorktreePath ? ( | 75 | <> |
| 137 | <> | 76 | <BranchToolbarEnvironmentSelector |
| 138 | <GitForkIcon className="size-3" /> | 77 | envLocked={envLocked} |
| 139 | Worktree | 78 | environmentId={environmentId} |
| 140 | </> | 79 | availableEnvironments={availableEnvironments} |
| 141 | ) : ( | 80 | onEnvironmentChange={onEnvironmentChange} |
| 142 | <> | 81 | /> |
| 143 | <FolderIcon className="size-3" /> | 82 | <Separator orientation="vertical" className="mx-0.5 h-3.5!" /> |
| 144 | Local | 83 | </> |
| 145 | </> | 84 | )} |
| 146 | )} | 85 | <BranchToolbarEnvModeSelector |
| 147 | </span> | 86 | envLocked={envLocked} |
| 148 | ) : ( | 87 | effectiveEnvMode={effectiveEnvMode} |
| 149 | <Select | 88 | activeWorktreePath={activeWorktreePath} |
| 150 | value={effectiveEnvMode} | 89 | onEnvModeChange={onEnvModeChange} |
| 151 | onValueChange={(value) => onEnvModeChange(value as EnvMode)} | 90 | /> |
| 152 | items={envModeItems} | 91 | </div> |
| 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 | )} | ||
| 178 | 92 | ||
| 179 | <BranchToolbarBranchSelector | 93 | <BranchToolbarBranchSelector |
| 180 | environmentId={environmentId} | 94 | environmentId={environmentId} |
| 181 | activeProjectCwd={activeProject.cwd} | 95 | threadId={threadId} |
| 182 | activeThreadBranch={activeThreadBranch} | 96 | {...(draftId ? { draftId } : {})} |
| 183 | activeWorktreePath={activeWorktreePath} | ||
| 184 | branchCwd={branchCwd} | ||
| 185 | effectiveEnvMode={effectiveEnvMode} | ||
| 186 | envLocked={envLocked} | 97 | envLocked={envLocked} |
| 187 | onSetThreadBranch={setThreadBranch} | ||
| 188 | {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} | 98 | {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} |
| 189 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} | 99 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} |
| 190 | /> | 100 | /> |
apps/web/src/components/BranchToolbarBranchSelector.tsx+127-33| ... | @@ -1,4 +1,5 @@ | ... | @@ -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 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; | 3 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useVirtualizer } from "@tanstack/react-virtual"; | 4 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | import { ChevronDownIcon } from "lucide-react"; | 5 | import { ChevronDownIcon } from "lucide-react"; |
| ... | @@ -14,15 +15,20 @@ import { | ... | @@ -14,15 +15,20 @@ import { |
| 14 | useTransition, | 15 | useTransition, |
| 15 | } from "react"; | 16 | } from "react"; |
| 16 | 17 | ||
| 18 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | ||
| 19 | import { readEnvironmentApi } from "../environmentApi"; | ||
| 17 | import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; | 20 | import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; |
| 18 | import { useGitStatus } from "../lib/gitStatusState"; | 21 | import { useGitStatus } from "../lib/gitStatusState"; |
| 19 | import { readEnvironmentApi } from "../environmentApi"; | 22 | import { newCommandId } from "../lib/utils"; |
| 20 | import { parsePullRequestReference } from "../pullRequestReference"; | 23 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 24 | import { useStore } from "../store"; | ||
| 25 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | ||
| 21 | import { | 26 | import { |
| 22 | deriveLocalBranchNameFromRemoteRef, | 27 | deriveLocalBranchNameFromRemoteRef, |
| 23 | EnvMode, | ||
| 24 | resolveBranchSelectionTarget, | 28 | resolveBranchSelectionTarget, |
| 25 | resolveBranchToolbarValue, | 29 | resolveBranchToolbarValue, |
| 30 | resolveDraftEnvModeAfterBranchChange, | ||
| 31 | resolveEffectiveEnvMode, | ||
| 26 | shouldIncludeBranchPickerItem, | 32 | shouldIncludeBranchPickerItem, |
| 27 | } from "./BranchToolbar.logic"; | 33 | } from "./BranchToolbar.logic"; |
| 28 | import { Button } from "./ui/button"; | 34 | import { Button } from "./ui/button"; |
| ... | @@ -40,13 +46,9 @@ import { toastManager } from "./ui/toast"; | ... | @@ -40,13 +46,9 @@ import { toastManager } from "./ui/toast"; |
| 40 | 46 | ||
| 41 | interface BranchToolbarBranchSelectorProps { | 47 | interface BranchToolbarBranchSelectorProps { |
| 42 | environmentId: EnvironmentId; | 48 | environmentId: EnvironmentId; |
| 43 | activeProjectCwd: string; | 49 | threadId: ThreadId; |
| 44 | activeThreadBranch: string | null; | 50 | draftId?: DraftId; |
| 45 | activeWorktreePath: string | null; | ||
| 46 | branchCwd: string | null; | ||
| 47 | effectiveEnvMode: EnvMode; | ||
| 48 | envLocked: boolean; | 51 | envLocked: boolean; |
| 49 | onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; | ||
| 50 | onCheckoutPullRequestRequest?: (reference: string) => void; | 52 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| 51 | onComposerFocusRequest?: () => void; | 53 | onComposerFocusRequest?: () => void; |
| 52 | } | 54 | } |
| ... | @@ -57,7 +59,7 @@ function toBranchActionErrorMessage(error: unknown): string { | ... | @@ -57,7 +59,7 @@ function toBranchActionErrorMessage(error: unknown): string { |
| 57 | 59 | ||
| 58 | function getBranchTriggerLabel(input: { | 60 | function getBranchTriggerLabel(input: { |
| 59 | activeWorktreePath: string | null; | 61 | activeWorktreePath: string | null; |
| 60 | effectiveEnvMode: EnvMode; | 62 | effectiveEnvMode: "local" | "worktree"; |
| 61 | resolvedActiveBranch: string | null; | 63 | resolvedActiveBranch: string | null; |
| 62 | }): string { | 64 | }): string { |
| 63 | const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; | 65 | const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; |
| ... | @@ -72,16 +74,109 @@ function getBranchTriggerLabel(input: { | ... | @@ -72,16 +74,109 @@ function getBranchTriggerLabel(input: { |
| 72 | 74 | ||
| 73 | export function BranchToolbarBranchSelector({ | 75 | export function BranchToolbarBranchSelector({ |
| 74 | environmentId, | 76 | environmentId, |
| 75 | activeProjectCwd, | 77 | threadId, |
| 76 | activeThreadBranch, | 78 | draftId, |
| 77 | activeWorktreePath, | ||
| 78 | branchCwd, | ||
| 79 | effectiveEnvMode, | ||
| 80 | envLocked, | 79 | envLocked, |
| 81 | onSetThreadBranch, | ||
| 82 | onCheckoutPullRequestRequest, | 80 | onCheckoutPullRequestRequest, |
| 83 | onComposerFocusRequest, | 81 | onComposerFocusRequest, |
| 84 | }: BranchToolbarBranchSelectorProps) { | 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 | const queryClient = useQueryClient(); | 180 | const queryClient = useQueryClient(); |
| 86 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); | 181 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 87 | const [branchQuery, setBranchQuery] = useState(""); | 182 | const [branchQuery, setBranchQuery] = useState(""); |
| ... | @@ -183,6 +278,9 @@ export function BranchToolbarBranchSelector({ | ... | @@ -183,6 +278,9 @@ export function BranchToolbarBranchSelector({ |
| 183 | ? `Showing ${branches.length} of ${totalBranchCount} branches` | 278 | ? `Showing ${branches.length} of ${totalBranchCount} branches` |
| 184 | : null; | 279 | : null; |
| 185 | 280 | ||
| 281 | // --------------------------------------------------------------------------- | ||
| 282 | // Branch actions | ||
| 283 | // --------------------------------------------------------------------------- | ||
| 186 | const runBranchAction = (action: () => Promise<void>) => { | 284 | const runBranchAction = (action: () => Promise<void>) => { |
| 187 | startBranchActionTransition(async () => { | 285 | startBranchActionTransition(async () => { |
| 188 | await action().catch(() => undefined); | 286 | await action().catch(() => undefined); |
| ... | @@ -194,11 +292,10 @@ export function BranchToolbarBranchSelector({ | ... | @@ -194,11 +292,10 @@ export function BranchToolbarBranchSelector({ |
| 194 | 292 | ||
| 195 | const selectBranch = (branch: GitBranch) => { | 293 | const selectBranch = (branch: GitBranch) => { |
| 196 | const api = readEnvironmentApi(environmentId); | 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 | if (isSelectingWorktreeBase) { | 297 | if (isSelectingWorktreeBase) { |
| 201 | onSetThreadBranch(branch.name, null); | 298 | setThreadBranch(branch.name, null); |
| 202 | setIsBranchMenuOpen(false); | 299 | setIsBranchMenuOpen(false); |
| 203 | onComposerFocusRequest?.(); | 300 | onComposerFocusRequest?.(); |
| 204 | return; | 301 | return; |
| ... | @@ -210,9 +307,8 @@ export function BranchToolbarBranchSelector({ | ... | @@ -210,9 +307,8 @@ export function BranchToolbarBranchSelector({ |
| 210 | branch, | 307 | branch, |
| 211 | }); | 308 | }); |
| 212 | 309 | ||
| 213 | // If the branch already lives in a worktree, point the thread there. | ||
| 214 | if (selectionTarget.reuseExistingWorktree) { | 310 | if (selectionTarget.reuseExistingWorktree) { |
| 215 | onSetThreadBranch(branch.name, selectionTarget.nextWorktreePath); | 311 | setThreadBranch(branch.name, selectionTarget.nextWorktreePath); |
| 216 | setIsBranchMenuOpen(false); | 312 | setIsBranchMenuOpen(false); |
| 217 | onComposerFocusRequest?.(); | 313 | onComposerFocusRequest?.(); |
| 218 | return; | 314 | return; |
| ... | @@ -237,7 +333,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -237,7 +333,7 @@ export function BranchToolbarBranchSelector({ |
| 237 | ? (checkoutResult.branch ?? selectedBranchName) | 333 | ? (checkoutResult.branch ?? selectedBranchName) |
| 238 | : selectedBranchName; | 334 | : selectedBranchName; |
| 239 | setOptimisticBranch(nextBranchName); | 335 | setOptimisticBranch(nextBranchName); |
| 240 | onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); | 336 | setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); |
| 241 | } catch (error) { | 337 | } catch (error) { |
| 242 | setOptimisticBranch(previousBranch); | 338 | setOptimisticBranch(previousBranch); |
| 243 | toastManager.add({ | 339 | toastManager.add({ |
| ... | @@ -267,7 +363,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -267,7 +363,7 @@ export function BranchToolbarBranchSelector({ |
| 267 | checkout: true, | 363 | checkout: true, |
| 268 | }); | 364 | }); |
| 269 | setOptimisticBranch(createBranchResult.branch); | 365 | setOptimisticBranch(createBranchResult.branch); |
| 270 | onSetThreadBranch(createBranchResult.branch, activeWorktreePath); | 366 | setThreadBranch(createBranchResult.branch, activeWorktreePath); |
| 271 | } catch (error) { | 367 | } catch (error) { |
| 272 | setOptimisticBranch(previousBranch); | 368 | setOptimisticBranch(previousBranch); |
| 273 | toastManager.add({ | 369 | toastManager.add({ |
| ... | @@ -288,15 +384,12 @@ export function BranchToolbarBranchSelector({ | ... | @@ -288,15 +384,12 @@ export function BranchToolbarBranchSelector({ |
| 288 | ) { | 384 | ) { |
| 289 | return; | 385 | return; |
| 290 | } | 386 | } |
| 291 | onSetThreadBranch(currentGitBranch, null); | 387 | setThreadBranch(currentGitBranch, null); |
| 292 | }, [ | 388 | }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); |
| 293 | activeThreadBranch, | ||
| 294 | activeWorktreePath, | ||
| 295 | currentGitBranch, | ||
| 296 | effectiveEnvMode, | ||
| 297 | onSetThreadBranch, | ||
| 298 | ]); | ||
| 299 | 389 | ||
| 390 | // --------------------------------------------------------------------------- | ||
| 391 | // Combobox / virtualizer plumbing | ||
| 392 | // --------------------------------------------------------------------------- | ||
| 300 | const handleOpenChange = useCallback( | 393 | const handleOpenChange = useCallback( |
| 301 | (open: boolean) => { | 394 | (open: boolean) => { |
| 302 | setIsBranchMenuOpen(open); | 395 | setIsBranchMenuOpen(open); |
| ... | @@ -437,7 +530,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -437,7 +530,7 @@ export function BranchToolbarBranchSelector({ |
| 437 | style={style} | 530 | style={style} |
| 438 | onClick={() => createBranch(trimmedBranchQuery)} | 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 | </ComboboxItem> | 534 | </ComboboxItem> |
| 442 | ); | 535 | ); |
| 443 | } | 536 | } |
| ... | @@ -445,7 +538,8 @@ export function BranchToolbarBranchSelector({ | ... | @@ -445,7 +538,8 @@ export function BranchToolbarBranchSelector({ |
| 445 | const branch = branchByName.get(itemValue); | 538 | const branch = branchByName.get(itemValue); |
| 446 | if (!branch) return null; | 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 | const badge = branch.current | 543 | const badge = branch.current |
| 450 | ? "current" | 544 | ? "current" |
| 451 | : hasSecondaryWorktree | 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,6 +44,7 @@ import { getRouter } from "../router"; |
| 44 | import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; | 44 | import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; |
| 45 | import { useTerminalStateStore } from "../terminalStateStore"; | 45 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 46 | import { useUiStateStore } from "../uiStateStore"; | 46 | import { useUiStateStore } from "../uiStateStore"; |
| 47 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; | ||
| 47 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; | 48 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; |
| 48 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | 49 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 49 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | 50 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| ... | @@ -148,6 +149,12 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -148,6 +149,12 @@ function createBaseServerConfig(): ServerConfig { |
| 148 | serverVersion: "0.0.0-test", | 149 | serverVersion: "0.0.0-test", |
| 149 | capabilities: { repositoryIdentity: true }, | 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 | cwd: "/repo/project", | 158 | cwd: "/repo/project", |
| 152 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", | 159 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 153 | keybindings: [], | 160 | keybindings: [], |
| ... | @@ -821,6 +828,7 @@ const worker = setupWorker( | ... | @@ -821,6 +828,7 @@ const worker = setupWorker( |
| 821 | void rpcHarness.onMessage(rawData); | 828 | void rpcHarness.onMessage(rawData); |
| 822 | }); | 829 | }); |
| 823 | }), | 830 | }), |
| 831 | ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), | ||
| 824 | http.get("*/attachments/:attachmentId", () => | 832 | http.get("*/attachments/:attachmentId", () => |
| 825 | HttpResponse.text(ATTACHMENT_SVG, { | 833 | HttpResponse.text(ATTACHMENT_SVG, { |
| 826 | headers: { | 834 | headers: { |
| ... | @@ -2629,10 +2637,9 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2629,10 +2637,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2629 | "Promoted drafts should canonicalize to the server thread route.", | 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. | 2640 | // The composer should remain usable after canonicalization, regardless of |
| 2633 | await expect | 2641 | // whether the promoted thread is still visibly empty or has already |
| 2634 | .element(page.getByText("Send a message to start the conversation.")) | 2642 | // entered the running state. |
| 2635 | .toBeInTheDocument(); | ||
| 2636 | await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); | 2643 | await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); |
| 2637 | } finally { | 2644 | } finally { |
| 2638 | await mounted.cleanup(); | 2645 | await mounted.cleanup(); |
| ... | @@ -2992,6 +2999,17 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2992,6 +2999,17 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2992 | const promotedThreadId = draftThreadIdFor(promotedDraftId); | 2999 | const promotedThreadId = draftThreadIdFor(promotedDraftId); |
| 2993 | 3000 | ||
| 2994 | await promoteDraftThreadViaDomainEvent(promotedThreadId); | 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 | const freshThreadPath = await triggerChatNewShortcutUntilPath( | 3014 | const freshThreadPath = await triggerChatNewShortcutUntilPath( |
| 2997 | mounted.router, | 3015 | mounted.router, |
apps/web/src/components/ChatView.tsx+224-53| ... | @@ -38,6 +38,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; | ... | @@ -38,6 +38,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 38 | import { useShallow } from "zustand/react/shallow"; | 38 | import { useShallow } from "zustand/react/shallow"; |
| 39 | import { useGitStatus } from "~/lib/gitStatusState"; | 39 | import { useGitStatus } from "~/lib/gitStatusState"; |
| 40 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | 40 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 41 | import { usePrimaryEnvironmentId } from "../environments/primary"; | ||
| 41 | import { readEnvironmentApi } from "../environmentApi"; | 42 | import { readEnvironmentApi } from "../environmentApi"; |
| 42 | import { isElectron } from "../env"; | 43 | import { isElectron } from "../env"; |
| 43 | import { readLocalApi } from "../localApi"; | 44 | import { readLocalApi } from "../localApi"; |
| ... | @@ -75,7 +76,11 @@ import { | ... | @@ -75,7 +76,11 @@ import { |
| 75 | togglePendingUserInputOptionSelection, | 76 | togglePendingUserInputOptionSelection, |
| 76 | type PendingUserInputDraftAnswer, | 77 | type PendingUserInputDraftAnswer, |
| 77 | } from "../pendingUserInput"; | 78 | } from "../pendingUserInput"; |
| 78 | import { selectThreadsAcrossEnvironments, useStore } from "../store"; | 79 | import { |
| 80 | selectProjectsAcrossEnvironments, | ||
| 81 | selectThreadsAcrossEnvironments, | ||
| 82 | useStore, | ||
| 83 | } from "../store"; | ||
| 79 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | 84 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; |
| 80 | import { useUiStateStore } from "../uiStateStore"; | 85 | import { useUiStateStore } from "../uiStateStore"; |
| 81 | import { | 86 | import { |
| ... | @@ -97,7 +102,7 @@ import { | ... | @@ -97,7 +102,7 @@ import { |
| 97 | import { basenameOfPath } from "../vscode-icons"; | 102 | import { basenameOfPath } from "../vscode-icons"; |
| 98 | import { useTheme } from "../hooks/useTheme"; | 103 | import { useTheme } from "../hooks/useTheme"; |
| 99 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 104 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 100 | import BranchToolbar from "./BranchToolbar"; | 105 | import { BranchToolbar } from "./BranchToolbar"; |
| 101 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 106 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 102 | import PlanSidebar from "./PlanSidebar"; | 107 | import PlanSidebar from "./PlanSidebar"; |
| 103 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 108 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| ... | @@ -124,7 +129,6 @@ import { | ... | @@ -124,7 +129,6 @@ import { |
| 124 | nextProjectScriptId, | 129 | nextProjectScriptId, |
| 125 | projectScriptIdFromCommand, | 130 | projectScriptIdFromCommand, |
| 126 | } from "~/projectScripts"; | 131 | } from "~/projectScripts"; |
| 127 | import { SidebarTrigger } from "./ui/sidebar"; | ||
| 128 | import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; | 132 | import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 129 | import { | 133 | import { |
| 130 | getProviderModelCapabilities, | 134 | getProviderModelCapabilities, |
| ... | @@ -135,6 +139,10 @@ import { useSettings } from "../hooks/useSettings"; | ... | @@ -135,6 +139,10 @@ import { useSettings } from "../hooks/useSettings"; |
| 135 | import { resolveAppModelSelection } from "../modelSelection"; | 139 | import { resolveAppModelSelection } from "../modelSelection"; |
| 136 | import { isTerminalFocused } from "../lib/terminalFocus"; | 140 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 137 | import { deriveLogicalProjectKey } from "../logicalProject"; | 141 | import { deriveLogicalProjectKey } from "../logicalProject"; |
| 142 | import { | ||
| 143 | useSavedEnvironmentRegistryStore, | ||
| 144 | useSavedEnvironmentRuntimeStore, | ||
| 145 | } from "../environments/runtime"; | ||
| 138 | import { buildDraftThreadRouteParams } from "../threadRoutes"; | 146 | import { buildDraftThreadRouteParams } from "../threadRoutes"; |
| 139 | import { | 147 | import { |
| 140 | type ComposerImageAttachment, | 148 | type ComposerImageAttachment, |
| ... | @@ -167,6 +175,7 @@ import { MessagesTimeline } from "./chat/MessagesTimeline"; | ... | @@ -167,6 +175,7 @@ import { MessagesTimeline } from "./chat/MessagesTimeline"; |
| 167 | import { ChatHeader } from "./chat/ChatHeader"; | 175 | import { ChatHeader } from "./chat/ChatHeader"; |
| 168 | import { ContextWindowMeter } from "./chat/ContextWindowMeter"; | 176 | import { ContextWindowMeter } from "./chat/ContextWindowMeter"; |
| 169 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; | 177 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 178 | import { NoActiveThreadState } from "./NoActiveThreadState"; | ||
| 170 | import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; | 179 | import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; |
| 171 | import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; | 180 | import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; |
| 172 | import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; | 181 | import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; |
| ... | @@ -212,7 +221,6 @@ import { | ... | @@ -212,7 +221,6 @@ import { |
| 212 | } from "~/rpc/serverState"; | 221 | } from "~/rpc/serverState"; |
| 213 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; | 222 | import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; |
| 214 | 223 | ||
| 215 | const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; | ||
| 216 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; | 224 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 217 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 225 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 218 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | 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,7 +821,7 @@ export default function ChatView(props: ChatViewProps) { |
| 813 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); | 821 | const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]); |
| 814 | const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null); | 822 | const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null); |
| 815 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); | 823 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 816 | const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({}); | 824 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); |
| 817 | const sendInFlightRef = useRef(false); | 825 | const sendInFlightRef = useRef(false); |
| 818 | const dragDepthRef = useRef(0); | 826 | const dragDepthRef = useRef(0); |
| 819 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); | 827 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| ... | @@ -998,6 +1006,54 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -998,6 +1006,54 @@ export default function ChatView(props: ChatViewProps) { |
| 998 | useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), | 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 | const openPullRequestDialog = useCallback( | 1057 | const openPullRequestDialog = useCallback( |
| 1002 | (reference?: string) => { | 1058 | (reference?: string) => { |
| 1003 | if (!canCheckoutPullRequestIntoThread) { | 1059 | if (!canCheckoutPullRequestIntoThread) { |
| ... | @@ -1127,7 +1183,17 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1127,7 +1183,17 @@ export default function ChatView(props: ChatViewProps) { |
| 1127 | const lockedProvider: ProviderKind | null = hasThreadStarted | 1183 | const lockedProvider: ProviderKind | null = hasThreadStarted |
| 1128 | ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) | 1184 | ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) |
| 1129 | : null; | 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 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; | 1197 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; |
| 1132 | const unlockedSelectedProvider = resolveSelectableProvider( | 1198 | const unlockedSelectedProvider = resolveSelectableProvider( |
| 1133 | providerStatuses, | 1199 | providerStatuses, |
| ... | @@ -1338,11 +1404,28 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1338,11 +1404,28 @@ export default function ChatView(props: ChatViewProps) { |
| 1338 | useEffect(() => { | 1404 | useEffect(() => { |
| 1339 | attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; | 1405 | attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; |
| 1340 | }, [attachmentPreviewHandoffByMessageId]); | 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 | const clearAttachmentPreviewHandoffs = useCallback(() => { | 1427 | const clearAttachmentPreviewHandoffs = useCallback(() => { |
| 1342 | for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) { | 1428 | attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; |
| 1343 | window.clearTimeout(timeoutId); | ||
| 1344 | } | ||
| 1345 | attachmentPreviewHandoffTimeoutByMessageIdRef.current = {}; | ||
| 1346 | for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { | 1429 | for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { |
| 1347 | for (const previewUrl of previewUrls) { | 1430 | for (const previewUrl of previewUrls) { |
| 1348 | revokeBlobPreviewUrl(previewUrl); | 1431 | revokeBlobPreviewUrl(previewUrl); |
| ... | @@ -1376,29 +1459,89 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1376,29 +1459,89 @@ export default function ChatView(props: ChatViewProps) { |
| 1376 | attachmentPreviewHandoffByMessageIdRef.current = next; | 1459 | attachmentPreviewHandoffByMessageIdRef.current = next; |
| 1377 | return next; | 1460 | return next; |
| 1378 | }); | 1461 | }); |
| 1379 | 1462 | }, []); | |
| 1380 | const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; | 1463 | const serverMessages = activeThread?.messages; |
| 1381 | if (typeof existingTimeout === "number") { | 1464 | useEffect(() => { |
| 1382 | window.clearTimeout(existingTimeout); | 1465 | if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) { |
| 1466 | return; | ||
| 1383 | } | 1467 | } |
| 1384 | attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => { | 1468 | |
| 1385 | const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId]; | 1469 | const cleanups: Array<() => void> = []; |
| 1386 | if (currentPreviewUrls) { | 1470 | |
| 1387 | for (const previewUrl of currentPreviewUrls) { | 1471 | for (const [messageId, handoffPreviewUrls] of Object.entries( |
| 1388 | revokeBlobPreviewUrl(previewUrl); | 1472 | attachmentPreviewHandoffByMessageId, |
| 1389 | } | 1473 | )) { |
| 1474 | if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { | ||
| 1475 | continue; | ||
| 1390 | } | 1476 | } |
| 1391 | setAttachmentPreviewHandoffByMessageId((existing) => { | 1477 | |
| 1392 | if (!(messageId in existing)) return existing; | 1478 | const serverMessage = serverMessages.find( |
| 1393 | const next = { ...existing }; | 1479 | (message) => message.id === messageId && message.role === "user", |
| 1394 | delete next[messageId]; | 1480 | ); |
| 1395 | attachmentPreviewHandoffByMessageIdRef.current = next; | 1481 | if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { |
| 1396 | return next; | 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]; | 1537 | } |
| 1399 | }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); | 1538 | |
| 1400 | }, []); | 1539 | return () => { |
| 1401 | const serverMessages = activeThread?.messages; | 1540 | for (const cleanup of cleanups) { |
| 1541 | cleanup(); | ||
| 1542 | } | ||
| 1543 | }; | ||
| 1544 | }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); | ||
| 1402 | const timelineMessages = useMemo(() => { | 1545 | const timelineMessages = useMemo(() => { |
| 1403 | const messages = serverMessages ?? []; | 1546 | const messages = serverMessages ?? []; |
| 1404 | const serverMessagesWithPreviewHandoff = | 1547 | const serverMessagesWithPreviewHandoff = |
| ... | @@ -1537,7 +1680,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1537,7 +1680,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1537 | const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); | 1680 | const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); |
| 1538 | const keybindings = useServerKeybindings(); | 1681 | const keybindings = useServerKeybindings(); |
| 1539 | const availableEditors = useServerAvailableEditors(); | 1682 | const availableEditors = useServerAvailableEditors(); |
| 1540 | const modelOptionsByProvider = useMemo( | 1683 | const modelOptionsByProvider = useMemo< |
| 1684 | Record<ProviderKind, ReadonlyArray<ServerProvider["models"][number]>> | ||
| 1685 | >( | ||
| 1541 | () => ({ | 1686 | () => ({ |
| 1542 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], | 1687 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], |
| 1543 | claudeAgent: | 1688 | claudeAgent: |
| ... | @@ -1730,6 +1875,47 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1730,6 +1875,47 @@ export default function ChatView(props: ChatViewProps) { |
| 1730 | (activeThread.messages.length > 0 || | 1875 | (activeThread.messages.length > 0 || |
| 1731 | (activeThread.session !== null && activeThread.session.status !== "closed")), | 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 | const activeTerminalGroup = | 1919 | const activeTerminalGroup = |
| 1734 | terminalState.terminalGroups.find( | 1920 | terminalState.terminalGroups.find( |
| 1735 | (group) => group.id === terminalState.activeTerminalGroupId, | 1921 | (group) => group.id === terminalState.activeTerminalGroupId, |
| ... | @@ -4113,28 +4299,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4113,28 +4299,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4113 | 4299 | ||
| 4114 | // Empty state: no active thread | 4300 | // Empty state: no active thread |
| 4115 | if (!activeThread) { | 4301 | if (!activeThread) { |
| 4116 | return ( | 4302 | return <NoActiveThreadState />; |
| 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 | ); | ||
| 4138 | } | 4303 | } |
| 4139 | 4304 | ||
| 4140 | return ( | 4305 | return ( |
| ... | @@ -4636,6 +4801,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4636,6 +4801,12 @@ export default function ChatView(props: ChatViewProps) { |
| 4636 | {...(canCheckoutPullRequestIntoThread | 4801 | {...(canCheckoutPullRequestIntoThread |
| 4637 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } | 4802 | ? { onCheckoutPullRequestRequest: openPullRequestDialog } |
| 4638 | : {})} | 4803 | : {})} |
| 4804 | {...(hasMultipleEnvironments | ||
| 4805 | ? { | ||
| 4806 | availableEnvironments: logicalProjectEnvironments, | ||
| 4807 | onEnvironmentChange, | ||
| 4808 | } | ||
| 4809 | : {})} | ||
| 4639 | /> | 4810 | /> |
| 4640 | )} | 4811 | )} |
| 4641 | {pullRequestDialogState ? ( | 4812 | {pullRequestDialogState ? ( |
apps/web/src/components/GitActionsControl.browser.tsx+18-1| ... | @@ -44,8 +44,12 @@ const { | ... | @@ -44,8 +44,12 @@ const { |
| 44 | toastUpdateSpy: vi.fn(), | 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 | return { | 51 | return { |
| 52 | ...actual, | ||
| 49 | useIsMutating: vi.fn(() => 0), | 53 | useIsMutating: vi.fn(() => 0), |
| 50 | useMutation: vi.fn((options: { __kind?: string }) => { | 54 | useMutation: vi.fn((options: { __kind?: string }) => { |
| 51 | if (options.__kind === "run-stacked-action") { | 55 | if (options.__kind === "run-stacked-action") { |
| ... | @@ -133,6 +137,19 @@ vi.mock("~/store", () => ({ | ... | @@ -133,6 +137,19 @@ vi.mock("~/store", () => ({ |
| 133 | } | 137 | } |
| 134 | return environmentState; | 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 | useStore: (selector: (state: unknown) => unknown) => | 153 | useStore: (selector: (state: unknown) => unknown) => |
| 137 | selector({ | 154 | selector({ |
| 138 | setThreadBranch: setThreadBranchSpy, | 155 | setThreadBranch: setThreadBranchSpy, |
apps/web/src/components/KeybindingsToast.browser.tsx+44| ... | @@ -22,8 +22,10 @@ import { useComposerDraftStore } from "../composerDraftStore"; | ... | @@ -22,8 +22,10 @@ import { useComposerDraftStore } from "../composerDraftStore"; |
| 22 | import { __resetLocalApiForTests } from "../localApi"; | 22 | import { __resetLocalApiForTests } from "../localApi"; |
| 23 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | 23 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; |
| 24 | import { getServerConfig } from "../rpc/serverState"; | 24 | import { getServerConfig } from "../rpc/serverState"; |
| 25 | import { getWsConnectionStatus } from "../rpc/wsConnectionState"; | ||
| 25 | import { getRouter } from "../router"; | 26 | import { getRouter } from "../router"; |
| 26 | import { useStore } from "../store"; | 27 | import { useStore } from "../store"; |
| 28 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; | ||
| 27 | import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; | 29 | import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; |
| 28 | 30 | ||
| 29 | vi.mock("../lib/gitStatusState", () => ({ | 31 | vi.mock("../lib/gitStatusState", () => ({ |
| ... | @@ -58,6 +60,12 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -58,6 +60,12 @@ function createBaseServerConfig(): ServerConfig { |
| 58 | serverVersion: "0.0.0-test", | 60 | serverVersion: "0.0.0-test", |
| 59 | capabilities: { repositoryIdentity: true }, | 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 | cwd: "/repo/project", | 69 | cwd: "/repo/project", |
| 62 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", | 70 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 63 | keybindings: [], | 71 | keybindings: [], |
| ... | @@ -210,6 +218,7 @@ const worker = setupWorker( | ... | @@ -210,6 +218,7 @@ const worker = setupWorker( |
| 210 | void rpcHarness.onMessage(rawData); | 218 | void rpcHarness.onMessage(rawData); |
| 211 | }); | 219 | }); |
| 212 | }), | 220 | }), |
| 221 | ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), | ||
| 213 | http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), | 222 | http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), |
| 214 | http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), | 223 | http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), |
| 215 | ); | 224 | ); |
| ... | @@ -250,6 +259,22 @@ async function waitForComposerEditor(): Promise<HTMLElement> { | ... | @@ -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 | async function waitForToast(title: string, count = 1): Promise<void> { | 278 | async function waitForToast(title: string, count = 1): Promise<void> { |
| 254 | await vi.waitFor( | 279 | await vi.waitFor( |
| 255 | () => { | 280 | () => { |
| ... | @@ -269,6 +294,20 @@ async function waitForNoToast(title: string): Promise<void> { | ... | @@ -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 | async function waitForInitialWsSubscriptions(): Promise<void> { | 311 | async function waitForInitialWsSubscriptions(): Promise<void> { |
| 273 | await vi.waitFor( | 312 | await vi.waitFor( |
| 274 | () => { | 313 | () => { |
| ... | @@ -313,8 +352,11 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { | ... | @@ -313,8 +352,11 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { |
| 313 | { container: host }, | 352 | { container: host }, |
| 314 | ); | 353 | ); |
| 315 | await waitForComposerEditor(); | 354 | await waitForComposerEditor(); |
| 355 | await waitForToastViewport(); | ||
| 316 | await waitForInitialWsSubscriptions(); | 356 | await waitForInitialWsSubscriptions(); |
| 357 | await waitForWsConnection(); | ||
| 317 | await waitForServerConfigSnapshot(); | 358 | await waitForServerConfigSnapshot(); |
| 359 | await waitForNoToasts(); | ||
| 318 | 360 | ||
| 319 | return { | 361 | return { |
| 320 | cleanup: async () => { | 362 | cleanup: async () => { |
| ... | @@ -387,6 +429,8 @@ describe("Keybindings update toast", () => { | ... | @@ -387,6 +429,8 @@ describe("Keybindings update toast", () => { |
| 387 | const mounted = await mountApp(); | 429 | const mounted = await mountApp(); |
| 388 | 430 | ||
| 389 | try { | 431 | try { |
| 432 | await warmServerConfigUpdateStream(); | ||
| 433 | |||
| 390 | sendServerConfigUpdatedPush([]); | 434 | sendServerConfigUpdatedPush([]); |
| 391 | await waitForToast("Keybindings updated", 1); | 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,14 +1,19 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 1 | import { FolderIcon } from "lucide-react"; | 2 | import { FolderIcon } from "lucide-react"; |
| 2 | import { useState } from "react"; | 3 | import { useState } from "react"; |
| 3 | import { resolveServerUrl } from "~/lib/utils"; | 4 | import { resolveEnvironmentHttpUrl } from "../environments/runtime"; |
| 4 | 5 | ||
| 5 | const loadedProjectFaviconSrcs = new Set<string>(); | 6 | const loadedProjectFaviconSrcs = new Set<string>(); |
| 6 | 7 | ||
| 7 | export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) { | 8 | export function ProjectFavicon(input: { |
| 8 | const src = resolveServerUrl({ | 9 | environmentId: EnvironmentId; |
| 9 | protocol: "http", | 10 | cwd: string; |
| 11 | className?: string; | ||
| 12 | }) { | ||
| 13 | const src = resolveEnvironmentHttpUrl({ | ||
| 14 | environmentId: input.environmentId, | ||
| 10 | pathname: "/api/project-favicon", | 15 | pathname: "/api/project-favicon", |
| 11 | searchParams: { cwd }, | 16 | searchParams: { cwd: input.cwd }, |
| 12 | }); | 17 | }); |
| 13 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => | 18 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => |
| 14 | loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", | 19 | loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", |
| ... | @@ -17,12 +22,14 @@ export function ProjectFavicon({ cwd, className }: { cwd: string; className?: st | ... | @@ -17,12 +22,14 @@ export function ProjectFavicon({ cwd, className }: { cwd: string; className?: st |
| 17 | return ( | 22 | return ( |
| 18 | <> | 23 | <> |
| 19 | {status !== "loaded" ? ( | 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 | ) : null} | 28 | ) : null} |
| 22 | <img | 29 | <img |
| 23 | src={src} | 30 | src={src} |
| 24 | alt="" | 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 | onLoad={() => { | 33 | onLoad={() => { |
| 27 | loadedProjectFaviconSrcs.add(src); | 34 | loadedProjectFaviconSrcs.add(src); |
| 28 | setStatus("loaded"); | 35 | setStatus("loaded"); |
apps/web/src/components/Sidebar.tsx+279-87| ... | @@ -2,6 +2,7 @@ import { | ... | @@ -2,6 +2,7 @@ import { |
| 2 | ArchiveIcon, | 2 | ArchiveIcon, |
| 3 | ArrowUpDownIcon, | 3 | ArrowUpDownIcon, |
| 4 | ChevronRightIcon, | 4 | ChevronRightIcon, |
| 5 | CloudIcon, | ||
| 5 | FolderIcon, | 6 | FolderIcon, |
| 6 | GitPullRequestIcon, | 7 | GitPullRequestIcon, |
| 7 | PlusIcon, | 8 | PlusIcon, |
| ... | @@ -34,6 +35,7 @@ import { | ... | @@ -34,6 +35,7 @@ import { |
| 34 | type DesktopUpdateState, | 35 | type DesktopUpdateState, |
| 35 | type EnvironmentId, | 36 | type EnvironmentId, |
| 36 | ProjectId, | 37 | ProjectId, |
| 38 | type ScopedProjectRef, | ||
| 37 | type ScopedThreadRef, | 39 | type ScopedThreadRef, |
| 38 | type ThreadEnvMode, | 40 | type ThreadEnvMode, |
| 39 | ThreadId, | 41 | ThreadId, |
| ... | @@ -50,13 +52,16 @@ import { | ... | @@ -50,13 +52,16 @@ import { |
| 50 | type SidebarProjectSortOrder, | 52 | type SidebarProjectSortOrder, |
| 51 | type SidebarThreadSortOrder, | 53 | type SidebarThreadSortOrder, |
| 52 | } from "@t3tools/contracts/settings"; | 54 | } from "@t3tools/contracts/settings"; |
| 55 | import { usePrimaryEnvironmentId } from "../environments/primary"; | ||
| 53 | import { isElectron } from "../env"; | 56 | import { isElectron } from "../env"; |
| 54 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; | 57 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 55 | import { isTerminalFocused } from "../lib/terminalFocus"; | 58 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 56 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; | 59 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; |
| 57 | import { | 60 | import { |
| 61 | selectProjectByRef, | ||
| 58 | selectProjectsAcrossEnvironments, | 62 | selectProjectsAcrossEnvironments, |
| 59 | selectSidebarThreadsForProjectRef, | 63 | selectSidebarThreadsForProjectRef, |
| 64 | selectSidebarThreadsForProjectRefs, | ||
| 60 | selectSidebarThreadsAcrossEnvironments, | 65 | selectSidebarThreadsAcrossEnvironments, |
| 61 | selectThreadByRef, | 66 | selectThreadByRef, |
| 62 | useStore, | 67 | useStore, |
| ... | @@ -103,7 +108,6 @@ import { | ... | @@ -103,7 +108,6 @@ import { |
| 103 | SidebarFooter, | 108 | SidebarFooter, |
| 104 | SidebarGroup, | 109 | SidebarGroup, |
| 105 | SidebarHeader, | 110 | SidebarHeader, |
| 106 | SidebarMenuAction, | ||
| 107 | SidebarMenu, | 111 | SidebarMenu, |
| 108 | SidebarMenuButton, | 112 | SidebarMenuButton, |
| 109 | SidebarMenuItem, | 113 | SidebarMenuItem, |
| ... | @@ -135,6 +139,11 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | ... | @@ -135,6 +139,11 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 135 | import { readEnvironmentApi } from "../environmentApi"; | 139 | import { readEnvironmentApi } from "../environmentApi"; |
| 136 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | 140 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 137 | import { useServerKeybindings } from "../rpc/serverState"; | 141 | import { useServerKeybindings } from "../rpc/serverState"; |
| 142 | import { deriveLogicalProjectKey } from "../logicalProject"; | ||
| 143 | import { | ||
| 144 | useSavedEnvironmentRegistryStore, | ||
| 145 | useSavedEnvironmentRuntimeStore, | ||
| 146 | } from "../environments/runtime"; | ||
| 138 | import type { Project, SidebarThreadSummary } from "../types"; | 147 | import type { Project, SidebarThreadSummary } from "../types"; |
| 139 | const THREAD_PREVIEW_LIMIT = 6; | 148 | const THREAD_PREVIEW_LIMIT = 6; |
| 140 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { | 149 | const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = { |
| ... | @@ -200,8 +209,14 @@ function buildThreadJumpLabelMap(input: { | ... | @@ -200,8 +209,14 @@ function buildThreadJumpLabelMap(input: { |
| 200 | return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; | 209 | return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; |
| 201 | } | 210 | } |
| 202 | 211 | ||
| 212 | type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; | ||
| 213 | |||
| 203 | type SidebarProjectSnapshot = Project & { | 214 | type SidebarProjectSnapshot = Project & { |
| 204 | projectKey: string; | 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 | interface TerminalStatusIndicator { | 221 | interface TerminalStatusIndicator { |
| 207 | label: "Terminal process running"; | 222 | label: "Terminal process running"; |
| ... | @@ -381,7 +396,30 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -381,7 +396,30 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 381 | (state) => | 396 | (state) => |
| 382 | selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, | 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 | const gitStatus = useGitStatus({ | 423 | const gitStatus = useGitStatus({ |
| 386 | environmentId: thread.environmentId, | 424 | environmentId: thread.environmentId, |
| 387 | cwd: thread.branch != null ? gitCwd : null, | 425 | cwd: thread.branch != null ? gitCwd : null, |
| ... | @@ -673,24 +711,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -673,24 +711,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 673 | ) | 711 | ) |
| 674 | ) : null} | 712 | ) : null} |
| 675 | <span className={threadMetaClassName}> | 713 | <span className={threadMetaClassName}> |
| 676 | {jumpLabel ? ( | 714 | <span className="inline-flex items-center gap-1"> |
| 677 | <span | 715 | {isRemoteThread && ( |
| 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" | 716 | <Tooltip> |
| 679 | title={jumpLabel} | 717 | <TooltipTrigger |
| 680 | > | 718 | render={ |
| 681 | {jumpLabel} | 719 | <span |
| 682 | </span> | 720 | aria-label={threadEnvironmentLabel ?? "Remote"} |
| 683 | ) : ( | 721 | className="inline-flex items-center justify-center" |
| 684 | <span | 722 | /> |
| 685 | className={`text-[10px] ${ | 723 | } |
| 686 | isHighlighted | 724 | > |
| 687 | ? "text-foreground/72 dark:text-foreground/82" | 725 | <CloudIcon className="size-3 text-muted-foreground/40" /> |
| 688 | : "text-muted-foreground/40" | 726 | </TooltipTrigger> |
| 689 | }`} | 727 | <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup> |
| 690 | > | 728 | </Tooltip> |
| 691 | {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} | 729 | )} |
| 692 | </span> | 730 | {jumpLabel ? ( |
| 693 | )} | 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 | </span> | 749 | </span> |
| 695 | </div> | 750 | </div> |
| 696 | </div> | 751 | </div> |
| ... | @@ -998,7 +1053,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -998,7 +1053,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 998 | const sidebarThreads = useStore( | 1053 | const sidebarThreads = useStore( |
| 999 | useShallow( | 1054 | useShallow( |
| 1000 | useMemo( | 1055 | useMemo( |
| 1001 | () => (state) => | 1056 | () => (state: import("../store").AppState) => |
| 1002 | selectSidebarThreadsForProjectRef( | 1057 | selectSidebarThreadsForProjectRef( |
| 1003 | state, | 1058 | state, |
| 1004 | scopeProjectRef(project.environmentId, project.id), | 1059 | scopeProjectRef(project.environmentId, project.id), |
| ... | @@ -1007,25 +1062,45 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -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 | const sidebarThreadByKey = useMemo( | 1091 | const sidebarThreadByKey = useMemo( |
| 1011 | () => | 1092 | () => |
| 1012 | new Map( | 1093 | new Map( |
| 1013 | sidebarThreads.map( | 1094 | allSidebarThreads.map( |
| 1014 | (thread) => | 1095 | (thread) => |
| 1015 | [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, | 1096 | [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, |
| 1016 | ), | 1097 | ), |
| 1017 | ), | 1098 | ), |
| 1018 | [sidebarThreads], | 1099 | [allSidebarThreads], |
| 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], | ||
| 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 | const projectExpanded = useUiStateStore( | 1104 | const projectExpanded = useUiStateStore( |
| 1030 | (state) => state.projectExpandedById[project.projectKey] ?? true, | 1105 | (state) => state.projectExpandedById[project.projectKey] ?? true, |
| 1031 | ); | 1106 | ); |
| ... | @@ -1639,28 +1714,49 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -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 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | 1718 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> |
| 1644 | {project.name} | 1719 | {project.name} |
| 1645 | </span> | 1720 | </span> |
| 1646 | </SidebarMenuButton> | 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 | <Tooltip> | 1746 | <Tooltip> |
| 1648 | <TooltipTrigger | 1747 | <TooltipTrigger |
| 1649 | render={ | 1748 | render={ |
| 1650 | <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"> |
| 1651 | render={ | 1750 | <button |
| 1652 | <button | 1751 | type="button" |
| 1653 | type="button" | 1752 | aria-label={`Create new thread in ${project.name}`} |
| 1654 | aria-label={`Create new thread in ${project.name}`} | 1753 | data-testid="new-thread-button" |
| 1655 | 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" |
| 1656 | /> | 1755 | onClick={handleCreateThreadClick} |
| 1657 | } | 1756 | > |
| 1658 | showOnHover | 1757 | <SquarePenIcon className="size-3.5" /> |
| 1659 | className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" | 1758 | </button> |
| 1660 | onClick={handleCreateThreadClick} | 1759 | </div> |
| 1661 | > | ||
| 1662 | <SquarePenIcon className="size-3.5" /> | ||
| 1663 | </SidebarMenuAction> | ||
| 1664 | } | 1760 | } |
| 1665 | /> | 1761 | /> |
| 1666 | <TooltipPopup side="top"> | 1762 | <TooltipPopup side="top"> |
| ... | @@ -2238,7 +2334,9 @@ export default function Sidebar() { | ... | @@ -2238,7 +2334,9 @@ export default function Sidebar() { |
| 2238 | const navigate = useNavigate(); | 2334 | const navigate = useNavigate(); |
| 2239 | const pathname = useLocation({ select: (loc) => loc.pathname }); | 2335 | const pathname = useLocation({ select: (loc) => loc.pathname }); |
| 2240 | const isOnSettings = pathname.startsWith("/settings"); | 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 | const { updateSettings } = useUpdateSettings(); | 2340 | const { updateSettings } = useUpdateSettings(); |
| 2243 | const { handleNewThread } = useHandleNewThread(); | 2341 | const { handleNewThread } = useHandleNewThread(); |
| 2244 | const { archiveThread, deleteThread } = useThreadActions(); | 2342 | const { archiveThread, deleteThread } = useThreadActions(); |
| ... | @@ -2269,6 +2367,9 @@ export default function Sidebar() { | ... | @@ -2269,6 +2367,9 @@ export default function Sidebar() { |
| 2269 | const platform = navigator.platform; | 2367 | const platform = navigator.platform; |
| 2270 | const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; | 2368 | const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; |
| 2271 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; | 2369 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; |
| 2370 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | ||
| 2371 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); | ||
| 2372 | const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); | ||
| 2272 | const orderedProjects = useMemo(() => { | 2373 | const orderedProjects = useMemo(() => { |
| 2273 | return orderItemsByPreferredIds({ | 2374 | return orderItemsByPreferredIds({ |
| 2274 | items: projects, | 2375 | items: projects, |
| ... | @@ -2276,14 +2377,89 @@ export default function Sidebar() { | ... | @@ -2276,14 +2377,89 @@ export default function Sidebar() { |
| 2276 | getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | 2377 | getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), |
| 2277 | }); | 2378 | }); |
| 2278 | }, [projectOrder, projects]); | 2379 | }, [projectOrder, projects]); |
| 2279 | const sidebarProjects = useMemo<SidebarProjectSnapshot[]>( | 2380 | |
| 2280 | () => | 2381 | // Build a mapping from physical project key → logical project key for |
| 2281 | orderedProjects.map((project) => ({ | 2382 | // cross-environment grouping. Projects that share a repositoryIdentity |
| 2282 | ...project, | 2383 | // canonicalKey are treated as one logical project in the sidebar. |
| 2283 | projectKey: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | 2384 | const physicalToLogicalKey = useMemo(() => { |
| 2284 | })), | 2385 | const mapping = new Map<string, string>(); |
| 2285 | [orderedProjects], | 2386 | for (const project of orderedProjects) { |
| 2286 | ); | 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 | const sidebarProjectByKey = useMemo( | 2463 | const sidebarProjectByKey = useMemo( |
| 2288 | () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), | 2464 | () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), |
| 2289 | [sidebarProjects], | 2465 | [sidebarProjects], |
| ... | @@ -2298,28 +2474,36 @@ export default function Sidebar() { | ... | @@ -2298,28 +2474,36 @@ export default function Sidebar() { |
| 2298 | ), | 2474 | ), |
| 2299 | [sidebarThreads], | 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 | const activeRouteProjectKey = useMemo(() => { | 2479 | const activeRouteProjectKey = useMemo(() => { |
| 2302 | if (!routeThreadKey) { | 2480 | if (!routeThreadKey) { |
| 2303 | return null; | 2481 | return null; |
| 2304 | } | 2482 | } |
| 2305 | const activeThread = sidebarThreadByKey.get(routeThreadKey); | 2483 | const activeThread = sidebarThreadByKey.get(routeThreadKey); |
| 2306 | return activeThread | 2484 | if (!activeThread) return null; |
| 2307 | ? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)) | 2485 | const physicalKey = scopedProjectKey( |
| 2308 | : null; | 2486 | scopeProjectRef(activeThread.environmentId, activeThread.projectId), |
| 2309 | }, [routeThreadKey, sidebarThreadByKey]); | 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 | const threadsByProjectKey = useMemo(() => { | 2493 | const threadsByProjectKey = useMemo(() => { |
| 2311 | const next = new Map<string, SidebarThreadSummary[]>(); | 2494 | const next = new Map<string, SidebarThreadSummary[]>(); |
| 2312 | for (const thread of sidebarThreads) { | 2495 | for (const thread of sidebarThreads) { |
| 2313 | const projectKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); | 2496 | const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); |
| 2314 | const existing = next.get(projectKey); | 2497 | const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; |
| 2498 | const existing = next.get(logicalKey); | ||
| 2315 | if (existing) { | 2499 | if (existing) { |
| 2316 | existing.push(thread); | 2500 | existing.push(thread); |
| 2317 | } else { | 2501 | } else { |
| 2318 | next.set(projectKey, [thread]); | 2502 | next.set(logicalKey, [thread]); |
| 2319 | } | 2503 | } |
| 2320 | } | 2504 | } |
| 2321 | return next; | 2505 | return next; |
| 2322 | }, [sidebarThreads]); | 2506 | }, [sidebarThreads, physicalToLogicalKey]); |
| 2323 | const getCurrentSidebarShortcutContext = useCallback( | 2507 | const getCurrentSidebarShortcutContext = useCallback( |
| 2324 | () => ({ | 2508 | () => ({ |
| 2325 | terminalFocus: isTerminalFocused(), | 2509 | terminalFocus: isTerminalFocused(), |
| ... | @@ -2347,12 +2531,13 @@ export default function Sidebar() { | ... | @@ -2347,12 +2531,13 @@ export default function Sidebar() { |
| 2347 | shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); | 2531 | shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); |
| 2348 | const focusMostRecentThreadForProject = useCallback( | 2532 | const focusMostRecentThreadForProject = useCallback( |
| 2349 | (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { | 2533 | (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { |
| 2350 | const projectKey = scopedProjectKey( | 2534 | const physicalKey = scopedProjectKey( |
| 2351 | scopeProjectRef(projectRef.environmentId, projectRef.projectId), | 2535 | scopeProjectRef(projectRef.environmentId, projectRef.projectId), |
| 2352 | ); | 2536 | ); |
| 2537 | const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; | ||
| 2353 | const latestThread = sortThreadsForSidebar( | 2538 | const latestThread = sortThreadsForSidebar( |
| 2354 | (threadsByProjectKey.get(projectKey) ?? []).filter((thread) => thread.archivedAt === null), | 2539 | (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), |
| 2355 | appSettings.sidebarThreadSortOrder, | 2540 | sidebarThreadSortOrder, |
| 2356 | )[0]; | 2541 | )[0]; |
| 2357 | if (!latestThread) return; | 2542 | if (!latestThread) return; |
| 2358 | 2543 | ||
| ... | @@ -2361,7 +2546,7 @@ export default function Sidebar() { | ... | @@ -2361,7 +2546,7 @@ export default function Sidebar() { |
| 2361 | params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), | 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 | const addProjectFromPath = useCallback( | 2552 | const addProjectFromPath = useCallback( |
| ... | @@ -2407,7 +2592,7 @@ export default function Sidebar() { | ... | @@ -2407,7 +2592,7 @@ export default function Sidebar() { |
| 2407 | }); | 2592 | }); |
| 2408 | if (activeEnvironmentId !== null) { | 2593 | if (activeEnvironmentId !== null) { |
| 2409 | await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { | 2594 | await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { |
| 2410 | envMode: appSettings.defaultThreadEnvMode, | 2595 | envMode: defaultThreadEnvMode, |
| 2411 | }).catch(() => undefined); | 2596 | }).catch(() => undefined); |
| 2412 | } | 2597 | } |
| 2413 | } catch (error) { | 2598 | } catch (error) { |
| ... | @@ -2434,7 +2619,7 @@ export default function Sidebar() { | ... | @@ -2434,7 +2619,7 @@ export default function Sidebar() { |
| 2434 | isAddingProject, | 2619 | isAddingProject, |
| 2435 | projects, | 2620 | projects, |
| 2436 | shouldBrowseForProjectImmediately, | 2621 | shouldBrowseForProjectImmediately, |
| 2437 | appSettings.defaultThreadEnvMode, | 2622 | defaultThreadEnvMode, |
| 2438 | ], | 2623 | ], |
| 2439 | ); | 2624 | ); |
| 2440 | 2625 | ||
| ... | @@ -2501,7 +2686,7 @@ export default function Sidebar() { | ... | @@ -2501,7 +2686,7 @@ export default function Sidebar() { |
| 2501 | 2686 | ||
| 2502 | const handleProjectDragEnd = useCallback( | 2687 | const handleProjectDragEnd = useCallback( |
| 2503 | (event: DragEndEvent) => { | 2688 | (event: DragEndEvent) => { |
| 2504 | if (appSettings.sidebarProjectSortOrder !== "manual") { | 2689 | if (sidebarProjectSortOrder !== "manual") { |
| 2505 | dragInProgressRef.current = false; | 2690 | dragInProgressRef.current = false; |
| 2506 | return; | 2691 | return; |
| 2507 | } | 2692 | } |
| ... | @@ -2513,18 +2698,18 @@ export default function Sidebar() { | ... | @@ -2513,18 +2698,18 @@ export default function Sidebar() { |
| 2513 | if (!activeProject || !overProject) return; | 2698 | if (!activeProject || !overProject) return; |
| 2514 | reorderProjects(activeProject.projectKey, overProject.projectKey); | 2699 | reorderProjects(activeProject.projectKey, overProject.projectKey); |
| 2515 | }, | 2700 | }, |
| 2516 | [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], | 2701 | [sidebarProjectSortOrder, reorderProjects, sidebarProjects], |
| 2517 | ); | 2702 | ); |
| 2518 | 2703 | ||
| 2519 | const handleProjectDragStart = useCallback( | 2704 | const handleProjectDragStart = useCallback( |
| 2520 | (_event: DragStartEvent) => { | 2705 | (_event: DragStartEvent) => { |
| 2521 | if (appSettings.sidebarProjectSortOrder !== "manual") { | 2706 | if (sidebarProjectSortOrder !== "manual") { |
| 2522 | return; | 2707 | return; |
| 2523 | } | 2708 | } |
| 2524 | dragInProgressRef.current = true; | 2709 | dragInProgressRef.current = true; |
| 2525 | suppressProjectClickAfterDragRef.current = true; | 2710 | suppressProjectClickAfterDragRef.current = true; |
| 2526 | }, | 2711 | }, |
| 2527 | [appSettings.sidebarProjectSortOrder], | 2712 | [sidebarProjectSortOrder], |
| 2528 | ); | 2713 | ); |
| 2529 | 2714 | ||
| 2530 | const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { | 2715 | const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { |
| ... | @@ -2558,22 +2743,29 @@ export default function Sidebar() { | ... | @@ -2558,22 +2743,29 @@ export default function Sidebar() { |
| 2558 | ...project, | 2743 | ...project, |
| 2559 | id: project.projectKey, | 2744 | id: project.projectKey, |
| 2560 | })); | 2745 | })); |
| 2561 | const sortableThreads = visibleThreads.map((thread) => ({ | 2746 | const sortableThreads = visibleThreads.map((thread) => { |
| 2562 | ...thread, | 2747 | const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); |
| 2563 | projectId: scopedProjectKey( | 2748 | return { |
| 2564 | scopeProjectRef(thread.environmentId, thread.projectId), | 2749 | ...thread, |
| 2565 | ) as ProjectId, | 2750 | projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, |
| 2566 | })); | 2751 | }; |
| 2752 | }); | ||
| 2567 | return sortProjectsForSidebar( | 2753 | return sortProjectsForSidebar( |
| 2568 | sortableProjects, | 2754 | sortableProjects, |
| 2569 | sortableThreads, | 2755 | sortableThreads, |
| 2570 | appSettings.sidebarProjectSortOrder, | 2756 | sidebarProjectSortOrder, |
| 2571 | ).flatMap((project) => { | 2757 | ).flatMap((project) => { |
| 2572 | const resolvedProject = sidebarProjectByKey.get(project.id); | 2758 | const resolvedProject = sidebarProjectByKey.get(project.id); |
| 2573 | return resolvedProject ? [resolvedProject] : []; | 2759 | return resolvedProject ? [resolvedProject] : []; |
| 2574 | }); | 2760 | }); |
| 2575 | }, [appSettings.sidebarProjectSortOrder, sidebarProjectByKey, sidebarProjects, visibleThreads]); | 2761 | }, [ |
| 2576 | const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; | 2762 | sidebarProjectSortOrder, |
| 2763 | physicalToLogicalKey, | ||
| 2764 | sidebarProjectByKey, | ||
| 2765 | sidebarProjects, | ||
| 2766 | visibleThreads, | ||
| 2767 | ]); | ||
| 2768 | const isManualProjectSorting = sidebarProjectSortOrder === "manual"; | ||
| 2577 | const visibleSidebarThreadKeys = useMemo( | 2769 | const visibleSidebarThreadKeys = useMemo( |
| 2578 | () => | 2770 | () => |
| 2579 | sortedProjects.flatMap((project) => { | 2771 | sortedProjects.flatMap((project) => { |
| ... | @@ -2581,7 +2773,7 @@ export default function Sidebar() { | ... | @@ -2581,7 +2773,7 @@ export default function Sidebar() { |
| 2581 | (threadsByProjectKey.get(project.projectKey) ?? []).filter( | 2773 | (threadsByProjectKey.get(project.projectKey) ?? []).filter( |
| 2582 | (thread) => thread.archivedAt === null, | 2774 | (thread) => thread.archivedAt === null, |
| 2583 | ), | 2775 | ), |
| 2584 | appSettings.sidebarThreadSortOrder, | 2776 | sidebarThreadSortOrder, |
| 2585 | ); | 2777 | ); |
| 2586 | const projectExpanded = projectExpandedById[project.projectKey] ?? true; | 2778 | const projectExpanded = projectExpandedById[project.projectKey] ?? true; |
| 2587 | const activeThreadKey = routeThreadKey ?? undefined; | 2779 | const activeThreadKey = routeThreadKey ?? undefined; |
| ... | @@ -2609,7 +2801,7 @@ export default function Sidebar() { | ... | @@ -2609,7 +2801,7 @@ export default function Sidebar() { |
| 2609 | ); | 2801 | ); |
| 2610 | }), | 2802 | }), |
| 2611 | [ | 2803 | [ |
| 2612 | appSettings.sidebarThreadSortOrder, | 2804 | sidebarThreadSortOrder, |
| 2613 | expandedThreadListsByProject, | 2805 | expandedThreadListsByProject, |
| 2614 | projectExpandedById, | 2806 | projectExpandedById, |
| 2615 | routeThreadKey, | 2807 | routeThreadKey, |
| ... | @@ -2910,8 +3102,8 @@ export default function Sidebar() { | ... | @@ -2910,8 +3102,8 @@ export default function Sidebar() { |
| 2910 | desktopUpdateButtonAction={desktopUpdateButtonAction} | 3102 | desktopUpdateButtonAction={desktopUpdateButtonAction} |
| 2911 | desktopUpdateButtonDisabled={desktopUpdateButtonDisabled} | 3103 | desktopUpdateButtonDisabled={desktopUpdateButtonDisabled} |
| 2912 | handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} | 3104 | handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} |
| 2913 | projectSortOrder={appSettings.sidebarProjectSortOrder} | 3105 | projectSortOrder={sidebarProjectSortOrder} |
| 2914 | threadSortOrder={appSettings.sidebarThreadSortOrder} | 3106 | threadSortOrder={sidebarThreadSortOrder} |
| 2915 | updateSettings={updateSettings} | 3107 | updateSettings={updateSettings} |
| 2916 | shouldShowProjectPathEntry={shouldShowProjectPathEntry} | 3108 | shouldShowProjectPathEntry={shouldShowProjectPathEntry} |
| 2917 | handleStartAddProject={handleStartAddProject} | 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,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 | const host = document.createElement("div"); | 158 | const host = document.createElement("div"); |
| 146 | host.style.width = "800px"; | 159 | host.style.width = "800px"; |
| 147 | host.style.height = "400px"; | 160 | host.style.height = "400px"; |
| 148 | document.body.append(host); | 161 | drawer.append(host); |
| 162 | document.body.append(drawer); | ||
| 149 | 163 | ||
| 150 | const screen = await render( | 164 | const screen = await render( |
| 151 | <TerminalViewport | 165 | <TerminalViewport |
| ... | @@ -184,7 +198,7 @@ async function mountTerminalViewport(props: { threadRef: ReturnType<typeof scope | ... | @@ -184,7 +198,7 @@ async function mountTerminalViewport(props: { threadRef: ReturnType<typeof scope |
| 184 | }, | 198 | }, |
| 185 | cleanup: async () => { | 199 | cleanup: async () => { |
| 186 | await screen.unmount(); | 200 | await screen.unmount(); |
| 187 | host.remove(); | 201 | drawer.remove(); |
| 188 | }, | 202 | }, |
| 189 | }; | 203 | }; |
| 190 | } | 204 | } |
| ... | @@ -244,4 +258,32 @@ describe("TerminalViewport", () => { | ... | @@ -244,4 +258,32 @@ describe("TerminalViewport", () => { |
| 244 | await mounted.cleanup(); | 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,12 +76,37 @@ export function selectPendingTerminalEventEntries( |
| 76 | return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); | 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 | const isDark = document.documentElement.classList.contains("dark"); | 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 | const bodyStyles = getComputedStyle(document.body); | 101 | const bodyStyles = getComputedStyle(document.body); |
| 82 | const background = | 102 | const background = normalizeComputedColor( |
| 83 | bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"); | 103 | drawerStyles.backgroundColor, |
| 84 | const foreground = bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"); | 104 | normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), |
| 105 | ); | ||
| 106 | const foreground = normalizeComputedColor( | ||
| 107 | drawerStyles.color, | ||
| 108 | normalizeComputedColor(bodyStyles.color, fallbackForeground), | ||
| 109 | ); | ||
| 85 | 110 | ||
| 86 | if (isDark) { | 111 | if (isDark) { |
| 87 | return { | 112 | return { |
| ... | @@ -275,7 +300,7 @@ export function TerminalViewport({ | ... | @@ -275,7 +300,7 @@ export function TerminalViewport({ |
| 275 | fontSize: 12, | 300 | fontSize: 12, |
| 276 | scrollback: 5_000, | 301 | scrollback: 5_000, |
| 277 | fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', | 302 | fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', |
| 278 | theme: terminalThemeFromApp(), | 303 | theme: terminalThemeFromApp(mount), |
| 279 | }); | 304 | }); |
| 280 | terminal.loadAddon(fitAddon); | 305 | terminal.loadAddon(fitAddon); |
| 281 | terminal.open(mount); | 306 | terminal.open(mount); |
| ... | @@ -488,7 +513,7 @@ export function TerminalViewport({ | ... | @@ -488,7 +513,7 @@ export function TerminalViewport({ |
| 488 | const themeObserver = new MutationObserver(() => { | 513 | const themeObserver = new MutationObserver(() => { |
| 489 | const activeTerminal = terminalRef.current; | 514 | const activeTerminal = terminalRef.current; |
| 490 | if (!activeTerminal) return; | 515 | if (!activeTerminal) return; |
| 491 | activeTerminal.options.theme = terminalThemeFromApp(); | 516 | activeTerminal.options.theme = terminalThemeFromApp(containerRef.current); |
| 492 | activeTerminal.refresh(0, activeTerminal.rows - 1); | 517 | activeTerminal.refresh(0, activeTerminal.rows - 1); |
| 493 | }); | 518 | }); |
| 494 | themeObserver.observe(document.documentElement, { | 519 | themeObserver.observe(document.documentElement, { |
| ... | @@ -720,7 +745,10 @@ export function TerminalViewport({ | ... | @@ -720,7 +745,10 @@ export function TerminalViewport({ |
| 720 | }; | 745 | }; |
| 721 | }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]); | 746 | }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]); |
| 722 | return ( | 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,9 +1,6 @@ |
| 1 | import { AlertTriangle, CloudOff, LoaderCircle, RotateCw } from "lucide-react"; | ||
| 2 | import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; | 1 | import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; |
| 3 | 2 | ||
| 4 | import { APP_DISPLAY_NAME } from "../branding"; | ||
| 5 | import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; | 3 | import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; |
| 6 | import { useServerConfig } from "../rpc/serverState"; | ||
| 7 | import { | 4 | import { |
| 8 | exhaustWsReconnectIfStillWaiting, | 5 | exhaustWsReconnectIfStillWaiting, |
| 9 | getWsConnectionStatus, | 6 | getWsConnectionStatus, |
| ... | @@ -14,9 +11,8 @@ import { | ... | @@ -14,9 +11,8 @@ import { |
| 14 | useWsConnectionStatus, | 11 | useWsConnectionStatus, |
| 15 | WS_RECONNECT_MAX_ATTEMPTS, | 12 | WS_RECONNECT_MAX_ATTEMPTS, |
| 16 | } from "../rpc/wsConnectionState"; | 13 | } from "../rpc/wsConnectionState"; |
| 17 | import { Button } from "./ui/button"; | ||
| 18 | import { toastManager } from "./ui/toast"; | 14 | import { toastManager } from "./ui/toast"; |
| 19 | import { getWsRpcClient } from "~/wsRpcClient"; | 15 | import { getPrimaryEnvironmentConnection } from "../environments/runtime"; |
| 20 | 16 | ||
| 21 | const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; | 17 | const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; |
| 22 | type WsAutoReconnectTrigger = "focus" | "online"; | 18 | type WsAutoReconnectTrigger = "focus" | "online"; |
| ... | @@ -58,11 +54,7 @@ function describeExhaustedToast(): string { | ... | @@ -58,11 +54,7 @@ function describeExhaustedToast(): string { |
| 58 | return "Retries exhausted trying to reconnect"; | 54 | return "Retries exhausted trying to reconnect"; |
| 59 | } | 55 | } |
| 60 | 56 | ||
| 61 | function buildReconnectTitle(status: WsConnectionStatus): string { | 57 | function buildReconnectTitle(_status: WsConnectionStatus): string { |
| 62 | if (status.nextRetryAt === null) { | ||
| 63 | return "Disconnected from T3 Server"; | ||
| 64 | } | ||
| 65 | |||
| 66 | return "Disconnected from T3 Server"; | 58 | return "Disconnected from T3 Server"; |
| 67 | } | 59 | } |
| 68 | 60 | ||
| ... | @@ -113,155 +105,6 @@ export function shouldAutoReconnect( | ... | @@ -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 | export function WebSocketConnectionCoordinator() { | 108 | export function WebSocketConnectionCoordinator() { |
| 266 | const status = useWsConnectionStatus(); | 109 | const status = useWsConnectionStatus(); |
| 267 | const [nowMs, setNowMs] = useState(() => Date.now()); | 110 | const [nowMs, setNowMs] = useState(() => Date.now()); |
| ... | @@ -277,7 +120,7 @@ export function WebSocketConnectionCoordinator() { | ... | @@ -277,7 +120,7 @@ export function WebSocketConnectionCoordinator() { |
| 277 | toastResetTimerRef.current = null; | 120 | toastResetTimerRef.current = null; |
| 278 | } | 121 | } |
| 279 | lastForcedReconnectAtRef.current = Date.now(); | 122 | lastForcedReconnectAtRef.current = Date.now(); |
| 280 | void getWsRpcClient() | 123 | void getPrimaryEnvironmentConnection() |
| 281 | .reconnect() | 124 | .reconnect() |
| 282 | .catch((error) => { | 125 | .catch((error) => { |
| 283 | if (!showFailureToast) { | 126 | if (!showFailureToast) { |
| ... | @@ -527,18 +370,5 @@ export function SlowRpcAckToastCoordinator() { | ... | @@ -527,18 +370,5 @@ export function SlowRpcAckToastCoordinator() { |
| 527 | } | 370 | } |
| 528 | 371 | ||
| 529 | export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { | 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 | return children; | 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,11 +1,17 @@ |
| 1 | import "../../index.css"; | 1 | import "../../index.css"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | type AuthAccessStreamEvent, | ||
| 5 | type AuthAccessSnapshot, | ||
| 6 | AuthSessionId, | ||
| 4 | DEFAULT_SERVER_SETTINGS, | 7 | DEFAULT_SERVER_SETTINGS, |
| 5 | EnvironmentId, | 8 | EnvironmentId, |
| 9 | type DesktopBridge, | ||
| 10 | type DesktopUpdateState, | ||
| 6 | type LocalApi, | 11 | type LocalApi, |
| 7 | type ServerConfig, | 12 | type ServerConfig, |
| 8 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 14 | import { DateTime } from "effect"; | ||
| 9 | import { page } from "vitest/browser"; | 15 | import { page } from "vitest/browser"; |
| 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 16 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 11 | import { render } from "vitest-browser-react"; | 17 | import { render } from "vitest-browser-react"; |
| ... | @@ -13,8 +19,155 @@ import { render } from "vitest-browser-react"; | ... | @@ -13,8 +19,155 @@ import { render } from "vitest-browser-react"; |
| 13 | import { __resetLocalApiForTests } from "../../localApi"; | 19 | import { __resetLocalApiForTests } from "../../localApi"; |
| 14 | import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; | 20 | import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; |
| 15 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; | 21 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; |
| 22 | import { ConnectionsSettings } from "./ConnectionsSettings"; | ||
| 16 | import { GeneralSettingsPanel } from "./SettingsPanels"; | 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 | function createBaseServerConfig(): ServerConfig { | 171 | function createBaseServerConfig(): ServerConfig { |
| 19 | return { | 172 | return { |
| 20 | environment: { | 173 | environment: { |
| ... | @@ -24,6 +177,12 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -24,6 +177,12 @@ function createBaseServerConfig(): ServerConfig { |
| 24 | serverVersion: "0.0.0-test", | 177 | serverVersion: "0.0.0-test", |
| 25 | capabilities: { repositoryIdentity: true }, | 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 | cwd: "/repo/project", | 186 | cwd: "/repo/project", |
| 28 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", | 187 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 29 | keybindings: [], | 188 | keybindings: [], |
| ... | @@ -41,24 +200,222 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -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 | describe("GeneralSettingsPanel observability", () => { | 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 | beforeEach(async () => { | 325 | beforeEach(async () => { |
| 46 | resetServerStateForTests(); | 326 | resetServerStateForTests(); |
| 47 | await __resetLocalApiForTests(); | 327 | await __resetLocalApiForTests(); |
| 48 | localStorage.clear(); | 328 | localStorage.clear(); |
| 49 | document.body.innerHTML = ""; | 329 | authAccessHarness.reset(); |
| 50 | }); | 330 | }); |
| 51 | 331 | ||
| 52 | afterEach(async () => { | 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 | resetServerStateForTests(); | 342 | resetServerStateForTests(); |
| 54 | await __resetLocalApiForTests(); | 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 | it("shows diagnostics inside About with a single logs-folder action", async () => { | 415 | it("shows diagnostics inside About with a single logs-folder action", async () => { |
| 59 | setServerConfigSnapshot(createBaseServerConfig()); | 416 | setServerConfigSnapshot(createBaseServerConfig()); |
| 60 | 417 | ||
| 61 | await render( | 418 | mounted = await render( |
| 62 | <AppAtomRegistryProvider> | 419 | <AppAtomRegistryProvider> |
| 63 | <GeneralSettingsPanel /> | 420 | <GeneralSettingsPanel /> |
| 64 | </AppAtomRegistryProvider>, | 421 | </AppAtomRegistryProvider>, |
| ... | @@ -79,6 +436,233 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -79,6 +436,233 @@ describe("GeneralSettingsPanel observability", () => { |
| 79 | .toBeInTheDocument(); | 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 | it("opens the logs folder in the preferred editor", async () => { | 666 | it("opens the logs folder in the preferred editor", async () => { |
| 83 | const openInEditor = vi.fn<LocalApi["shell"]["openInEditor"]>().mockResolvedValue(undefined); | 667 | const openInEditor = vi.fn<LocalApi["shell"]["openInEditor"]>().mockResolvedValue(undefined); |
| 84 | window.nativeApi = { | 668 | window.nativeApi = { |
| ... | @@ -89,7 +673,7 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -89,7 +673,7 @@ describe("GeneralSettingsPanel observability", () => { |
| 89 | 673 | ||
| 90 | setServerConfigSnapshot(createBaseServerConfig()); | 674 | setServerConfigSnapshot(createBaseServerConfig()); |
| 91 | 675 | ||
| 92 | await render( | 676 | mounted = await render( |
| 93 | <AppAtomRegistryProvider> | 677 | <AppAtomRegistryProvider> |
| 94 | <GeneralSettingsPanel /> | 678 | <GeneralSettingsPanel /> |
| 95 | </AppAtomRegistryProvider>, | 679 | </AppAtomRegistryProvider>, |
apps/web/src/components/settings/SettingsPanels.tsx+14-113| ... | @@ -6,11 +6,10 @@ import { | ... | @@ -6,11 +6,10 @@ import { |
| 6 | LoaderIcon, | 6 | LoaderIcon, |
| 7 | PlusIcon, | 7 | PlusIcon, |
| 8 | RefreshCwIcon, | 8 | RefreshCwIcon, |
| 9 | Undo2Icon, | ||
| 10 | XIcon, | 9 | XIcon, |
| 11 | } from "lucide-react"; | 10 | } from "lucide-react"; |
| 12 | import { useQueryClient } from "@tanstack/react-query"; | 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 | import { | 13 | import { |
| 15 | PROVIDER_DISPLAY_NAMES, | 14 | PROVIDER_DISPLAY_NAMES, |
| 16 | type ScopedThreadRef, | 15 | type ScopedThreadRef, |
| ... | @@ -47,9 +46,10 @@ import { | ... | @@ -47,9 +46,10 @@ import { |
| 47 | resolveAppModelSelectionState, | 46 | resolveAppModelSelectionState, |
| 48 | } from "../../modelSelection"; | 47 | } from "../../modelSelection"; |
| 49 | import { ensureLocalApi, readLocalApi } from "../../localApi"; | 48 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 49 | import { useShallow } from "zustand/react/shallow"; | ||
| 50 | import { | 50 | import { |
| 51 | selectProjectsAcrossEnvironments, | 51 | selectProjectsAcrossEnvironments, |
| 52 | selectThreadsAcrossEnvironments, | 52 | selectThreadShellsAcrossEnvironments, |
| 53 | useStore, | 53 | useStore, |
| 54 | } from "../../store"; | 54 | } from "../../store"; |
| 55 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; | 55 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; |
| ... | @@ -62,6 +62,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ | ... | @@ -62,6 +62,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ |
| 62 | import { Switch } from "../ui/switch"; | 62 | import { Switch } from "../ui/switch"; |
| 63 | import { toastManager } from "../ui/toast"; | 63 | import { toastManager } from "../ui/toast"; |
| 64 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 64 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 65 | import { | ||
| 66 | SettingResetButton, | ||
| 67 | SettingsPageContainer, | ||
| 68 | SettingsRow, | ||
| 69 | SettingsSection, | ||
| 70 | useRelativeTimeTick, | ||
| 71 | } from "./settingsLayout"; | ||
| 65 | import { ProjectFavicon } from "../ProjectFavicon"; | 72 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 66 | import { | 73 | import { |
| 67 | useServerAvailableEditors, | 74 | useServerAvailableEditors, |
| ... | @@ -191,15 +198,6 @@ function getProviderVersionLabel(version: string | null | undefined) { | ... | @@ -191,15 +198,6 @@ function getProviderVersionLabel(version: string | null | undefined) { |
| 191 | return version.startsWith("v") ? version : `v${version}`; | 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 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { | 201 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { |
| 204 | useRelativeTimeTick(); | 202 | useRelativeTimeTick(); |
| 205 | const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; | 203 | const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; |
| ... | @@ -222,104 +220,6 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | 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 | function AboutVersionTitle() { | 223 | function AboutVersionTitle() { |
| 324 | return ( | 224 | return ( |
| 325 | <span className="inline-flex items-center gap-2"> | 225 | <span className="inline-flex items-center gap-2"> |
| ... | @@ -781,6 +681,7 @@ export function GeneralSettingsPanel() { | ... | @@ -781,6 +681,7 @@ export function GeneralSettingsPanel() { |
| 781 | serverProviders[0]!.checkedAt, | 681 | serverProviders[0]!.checkedAt, |
| 782 | ) | 682 | ) |
| 783 | : null; | 683 | : null; |
| 684 | |||
| 784 | return ( | 685 | return ( |
| 785 | <SettingsPageContainer> | 686 | <SettingsPageContainer> |
| 786 | <SettingsSection title="General"> | 687 | <SettingsSection title="General"> |
| ... | @@ -1484,8 +1385,8 @@ export function GeneralSettingsPanel() { | ... | @@ -1484,8 +1385,8 @@ export function GeneralSettingsPanel() { |
| 1484 | } | 1385 | } |
| 1485 | 1386 | ||
| 1486 | export function ArchivedThreadsPanel() { | 1387 | export function ArchivedThreadsPanel() { |
| 1487 | const projects = useStore(selectProjectsAcrossEnvironments); | 1388 | const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); |
| 1488 | const threads = useStore(selectThreadsAcrossEnvironments); | 1389 | const threads = useStore(useShallow(selectThreadShellsAcrossEnvironments)); |
| 1489 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); | 1390 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); |
| 1490 | const archivedGroups = useMemo(() => { | 1391 | const archivedGroups = useMemo(() => { |
| 1491 | return projects | 1392 | return projects |
| ... | @@ -1553,7 +1454,7 @@ export function ArchivedThreadsPanel() { | ... | @@ -1553,7 +1454,7 @@ export function ArchivedThreadsPanel() { |
| 1553 | <SettingsSection | 1454 | <SettingsSection |
| 1554 | key={project.id} | 1455 | key={project.id} |
| 1555 | title={project.name} | 1456 | title={project.name} |
| 1556 | icon={<ProjectFavicon cwd={project.cwd} />} | 1457 | icon={<ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />} |
| 1557 | > | 1458 | > |
| 1558 | {projectThreads.map((thread) => ( | 1459 | {projectThreads.map((thread) => ( |
| 1559 | <div | 1460 | <div |
apps/web/src/components/settings/SettingsSidebarNav.tsx+9-5| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { ComponentType } from "react"; | 1 | import type { ComponentType } from "react"; |
| 2 | import { ArchiveIcon, ArrowLeftIcon, Settings2Icon } from "lucide-react"; | 2 | import { ArchiveIcon, ArrowLeftIcon, Link2Icon, Settings2Icon } from "lucide-react"; |
| 3 | import { useNavigate } from "@tanstack/react-router"; | 3 | import { useNavigate } from "@tanstack/react-router"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| ... | @@ -12,7 +12,10 @@ import { | ... | @@ -12,7 +12,10 @@ import { |
| 12 | SidebarSeparator, | 12 | SidebarSeparator, |
| 13 | } from "../ui/sidebar"; | 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 | export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ | 20 | export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 18 | label: string; | 21 | label: string; |
| ... | @@ -20,6 +23,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ | ... | @@ -20,6 +23,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 20 | icon: ComponentType<{ className?: string }>; | 23 | icon: ComponentType<{ className?: string }>; |
| 21 | }> = [ | 24 | }> = [ |
| 22 | { label: "General", to: "/settings/general", icon: Settings2Icon }, | 25 | { label: "General", to: "/settings/general", icon: Settings2Icon }, |
| 26 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, | ||
| 23 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, | 27 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, |
| 24 | ]; | 28 | ]; |
| 25 | 29 | ||
| ... | @@ -41,8 +45,8 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -41,8 +45,8 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 41 | isActive={isActive} | 45 | isActive={isActive} |
| 42 | className={ | 46 | className={ |
| 43 | isActive | 47 | isActive |
| 44 | ? "gap-2 px-2 py-2 text-left text-xs text-foreground" | 48 | ? "gap-2.5 px-2.5 py-2 text-left text-[13px] font-medium text-foreground" |
| 45 | : "gap-2 px-2 py-2 text-left text-xs text-muted-foreground hover:text-foreground/80" | 49 | : "gap-2.5 px-2.5 py-2 text-left text-[13px] text-muted-foreground/70 hover:text-foreground/80" |
| 46 | } | 50 | } |
| 47 | onClick={() => void navigate({ to: item.to, replace: true })} | 51 | onClick={() => void navigate({ to: item.to, replace: true })} |
| 48 | > | 52 | > |
| ... | @@ -50,7 +54,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -50,7 +54,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 50 | className={ | 54 | className={ |
| 51 | isActive | 55 | isActive |
| 52 | ? "size-4 shrink-0 text-foreground" | 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 | <span className="truncate">{item.label}</span> | 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,6 +800,50 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 800 | envMode: "worktree", | 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 | describe("composerDraftStore modelSelection", () => { | 849 | describe("composerDraftStore modelSelection", () => { |
apps/web/src/composerDraftStore.ts+39-7| ... | @@ -1001,10 +1001,22 @@ function createDraftThreadState( | ... | @@ -1001,10 +1001,22 @@ function createDraftThreadState( |
| 1001 | interactionMode?: ProviderInteractionMode; | 1001 | interactionMode?: ProviderInteractionMode; |
| 1002 | }, | 1002 | }, |
| 1003 | ): DraftThreadState { | 1003 | ): DraftThreadState { |
| 1004 | const projectChanged = | ||
| 1005 | existingThread !== undefined && | ||
| 1006 | (existingThread.environmentId !== projectRef.environmentId || | ||
| 1007 | existingThread.projectId !== projectRef.projectId); | ||
| 1004 | const nextWorktreePath = | 1008 | const nextWorktreePath = |
| 1005 | options?.worktreePath === undefined | 1009 | options?.worktreePath === undefined |
| 1006 | ? (existingThread?.worktreePath ?? null) | 1010 | ? projectChanged |
| 1011 | ? null | ||
| 1012 | : (existingThread?.worktreePath ?? null) | ||
| 1007 | : (options.worktreePath ?? null); | 1013 | : (options.worktreePath ?? null); |
| 1014 | const nextBranch = | ||
| 1015 | options?.branch === undefined | ||
| 1016 | ? projectChanged | ||
| 1017 | ? null | ||
| 1018 | : (existingThread?.branch ?? null) | ||
| 1019 | : (options.branch ?? null); | ||
| 1008 | return { | 1020 | return { |
| 1009 | threadId, | 1021 | threadId, |
| 1010 | environmentId: projectRef.environmentId, | 1022 | environmentId: projectRef.environmentId, |
| ... | @@ -1014,11 +1026,15 @@ function createDraftThreadState( | ... | @@ -1014,11 +1026,15 @@ function createDraftThreadState( |
| 1014 | runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, | 1026 | runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, |
| 1015 | interactionMode: | 1027 | interactionMode: |
| 1016 | options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, | 1028 | options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, |
| 1017 | branch: | 1029 | branch: nextBranch, |
| 1018 | options?.branch === undefined ? (existingThread?.branch ?? null) : (options.branch ?? null), | ||
| 1019 | worktreePath: nextWorktreePath, | 1030 | worktreePath: nextWorktreePath, |
| 1020 | envMode: | 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 | promotedTo: null, | 1038 | promotedTo: null, |
| 1023 | }; | 1039 | }; |
| 1024 | } | 1040 | } |
| ... | @@ -1908,10 +1924,21 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1908,10 +1924,21 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 1908 | ) { | 1924 | ) { |
| 1909 | return state; | 1925 | return state; |
| 1910 | } | 1926 | } |
| 1927 | const projectChanged = | ||
| 1928 | nextProjectRef.environmentId !== existing.environmentId || | ||
| 1929 | nextProjectRef.projectId !== existing.projectId; | ||
| 1911 | const nextWorktreePath = | 1930 | const nextWorktreePath = |
| 1912 | options.worktreePath === undefined | 1931 | options.worktreePath === undefined |
| 1913 | ? existing.worktreePath | 1932 | ? projectChanged |
| 1933 | ? null | ||
| 1934 | : existing.worktreePath | ||
| 1914 | : (options.worktreePath ?? null); | 1935 | : (options.worktreePath ?? null); |
| 1936 | const nextBranch = | ||
| 1937 | options.branch === undefined | ||
| 1938 | ? projectChanged | ||
| 1939 | ? null | ||
| 1940 | : existing.branch | ||
| 1941 | : (options.branch ?? null); | ||
| 1915 | const nextDraftThread: DraftThreadState = { | 1942 | const nextDraftThread: DraftThreadState = { |
| 1916 | threadId: existing.threadId, | 1943 | threadId: existing.threadId, |
| 1917 | environmentId: nextProjectRef.environmentId, | 1944 | environmentId: nextProjectRef.environmentId, |
| ... | @@ -1923,10 +1950,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1923,10 +1950,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 1923 | : options.createdAt || existing.createdAt, | 1950 | : options.createdAt || existing.createdAt, |
| 1924 | runtimeMode: options.runtimeMode ?? existing.runtimeMode, | 1951 | runtimeMode: options.runtimeMode ?? existing.runtimeMode, |
| 1925 | interactionMode: options.interactionMode ?? existing.interactionMode, | 1952 | interactionMode: options.interactionMode ?? existing.interactionMode, |
| 1926 | branch: options.branch === undefined ? existing.branch : (options.branch ?? null), | 1953 | branch: nextBranch, |
| 1927 | worktreePath: nextWorktreePath, | 1954 | worktreePath: nextWorktreePath, |
| 1928 | envMode: | 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 | promotedTo: existing.promotedTo ?? null, | 1962 | promotedTo: existing.promotedTo ?? null, |
| 1931 | }; | 1963 | }; |
| 1932 | const isUnchanged = | 1964 | const isUnchanged = |
apps/web/src/environmentApi.ts+4-3| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; | 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 | export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { | 6 | export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 6 | return { | 7 | return { |
| ... | @@ -54,8 +55,8 @@ export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi | ... | @@ -54,8 +55,8 @@ export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi |
| 54 | return undefined; | 55 | return undefined; |
| 55 | } | 56 | } |
| 56 | 57 | ||
| 57 | const entry = readWsRpcClientEntryForEnvironment(environmentId); | 58 | const connection = readEnvironmentConnection(environmentId); |
| 58 | return entry ? createEnvironmentApi(entry.client) : undefined; | 59 | return connection ? createEnvironmentApi(connection.client) : undefined; |
| 59 | } | 60 | } |
| 60 | 61 | ||
| 61 | export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi { | 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,6 +8,8 @@ type ThemeSnapshot = { |
| 8 | 8 | ||
| 9 | const STORAGE_KEY = "t3code:theme"; | 9 | const STORAGE_KEY = "t3code:theme"; |
| 10 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; | 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 | let listeners: Array<() => void> = []; | 14 | let listeners: Array<() => void> = []; |
| 13 | let lastSnapshot: ThemeSnapshot | null = null; | 15 | let lastSnapshot: ThemeSnapshot | null = null; |
| ... | @@ -26,12 +28,63 @@ function getStored(): Theme { | ... | @@ -26,12 +28,63 @@ function getStored(): Theme { |
| 26 | return "system"; | 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 | function applyTheme(theme: Theme, suppressTransitions = false) { | 80 | function applyTheme(theme: Theme, suppressTransitions = false) { |
| 81 | if (typeof document === "undefined") return; | ||
| 30 | if (suppressTransitions) { | 82 | if (suppressTransitions) { |
| 31 | document.documentElement.classList.add("no-transitions"); | 83 | document.documentElement.classList.add("no-transitions"); |
| 32 | } | 84 | } |
| 33 | const isDark = theme === "dark" || (theme === "system" && getSystemDark()); | 85 | const isDark = theme === "dark" || (theme === "system" && getSystemDark()); |
| 34 | document.documentElement.classList.toggle("dark", isDark); | 86 | document.documentElement.classList.toggle("dark", isDark); |
| 87 | syncBrowserChromeTheme(); | ||
| 35 | syncDesktopTheme(theme); | 88 | syncDesktopTheme(theme); |
| 36 | if (suppressTransitions) { | 89 | if (suppressTransitions) { |
| 37 | // Force a reflow so the no-transitions class takes effect before removal | 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,8 +47,12 @@ |
| 47 | * { | 47 | * { |
| 48 | @apply border-border outline-ring/50; | 48 | @apply border-border outline-ring/50; |
| 49 | } | 49 | } |
| 50 | html { | ||
| 51 | background-color: var(--app-chrome-background); | ||
| 52 | } | ||
| 50 | body { | 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,6 +69,7 @@ |
| 65 | color-scheme: light; | 69 | color-scheme: light; |
| 66 | --radius: 0.625rem; | 70 | --radius: 0.625rem; |
| 67 | --background: var(--color-white); | 71 | --background: var(--color-white); |
| 72 | --app-chrome-background: var(--background); | ||
| 68 | --foreground: var(--color-neutral-800); | 73 | --foreground: var(--color-neutral-800); |
| 69 | --card: var(--color-white); | 74 | --card: var(--color-white); |
| 70 | --card-foreground: var(--color-neutral-800); | 75 | --card-foreground: var(--color-neutral-800); |
| ... | @@ -93,6 +98,7 @@ | ... | @@ -93,6 +98,7 @@ |
| 93 | @variant dark { | 98 | @variant dark { |
| 94 | color-scheme: dark; | 99 | color-scheme: dark; |
| 95 | --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white)); | 100 | --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white)); |
| 101 | --app-chrome-background: var(--background); | ||
| 96 | --foreground: var(--color-neutral-100); | 102 | --foreground: var(--color-neutral-100); |
| 97 | --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); | 103 | --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); |
| 98 | --card-foreground: var(--color-neutral-100); | 104 | --card-foreground: var(--color-neutral-100); |
| ... | @@ -157,9 +163,7 @@ body::after { | ... | @@ -157,9 +163,7 @@ body::after { |
| 157 | } | 163 | } |
| 158 | 164 | ||
| 159 | pre, | 165 | pre, |
| 160 | code, | 166 | code { |
| 161 | textarea, | ||
| 162 | input { | ||
| 163 | font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; | 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,7 +11,7 @@ import { |
| 11 | type QueryClient, | 11 | type QueryClient, |
| 12 | } from "@tanstack/react-query"; | 12 | } from "@tanstack/react-query"; |
| 13 | import { ensureEnvironmentApi } from "../environmentApi"; | 13 | import { ensureEnvironmentApi } from "../environmentApi"; |
| 14 | import { getWsRpcClientForEnvironment } from "../wsRpcClient"; | 14 | import { requireEnvironmentConnection } from "../environments/runtime"; |
| 15 | 15 | ||
| 16 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; | 16 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; |
| 17 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; | 17 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; |
| ... | @@ -180,7 +180,7 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -180,7 +180,7 @@ export function gitRunStackedActionMutationOptions(input: { |
| 180 | onProgress?: (event: GitActionProgressEvent) => void; | 180 | onProgress?: (event: GitActionProgressEvent) => void; |
| 181 | }) => { | 181 | }) => { |
| 182 | if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable."); | 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 | action, | 185 | action, |
| 186 | actionId, | 186 | actionId, |
apps/web/src/lib/gitStatusState.test.ts+177-1| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts"; |
| 2 | import { afterEach, describe, expect, it, vi } from "vitest"; | 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 | import { | 6 | import { |
| 5 | getGitStatusSnapshot, | 7 | getGitStatusSnapshot, |
| 6 | resetGitStatusStateForTests, | 8 | resetGitStatusStateForTests, |
| ... | @@ -8,6 +10,22 @@ import { | ... | @@ -8,6 +10,22 @@ import { |
| 8 | watchGitStatus, | 10 | watchGitStatus, |
| 9 | } from "./gitStatusState"; | 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 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | 29 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { |
| 12 | listeners.add(listener); | 30 | listeners.add(listener); |
| 13 | return () => { | 31 | return () => { |
| ... | @@ -50,11 +68,110 @@ function emitGitStatus(event: GitStatusResult) { | ... | @@ -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 | gitStatusListeners.clear(); | 168 | gitStatusListeners.clear(); |
| 169 | serviceHarness.connections.clear(); | ||
| 170 | serviceHarness.listeners.clear(); | ||
| 55 | gitClient.onStatus.mockClear(); | 171 | gitClient.onStatus.mockClear(); |
| 56 | gitClient.refreshStatus.mockClear(); | 172 | gitClient.refreshStatus.mockClear(); |
| 57 | resetGitStatusStateForTests(); | 173 | resetGitStatusStateForTests(); |
| 174 | resetAppAtomRegistryForTests(); | ||
| 58 | }); | 175 | }); |
| 59 | 176 | ||
| 60 | describe("gitStatusState", () => { | 177 | describe("gitStatusState", () => { |
| ... | @@ -147,4 +264,63 @@ describe("gitStatusState", () => { | ... | @@ -147,4 +264,63 @@ describe("gitStatusState", () => { |
| 147 | releaseLocal(); | 264 | releaseLocal(); |
| 148 | releaseRemote(); | 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,25 +9,31 @@ import { Atom } from "effect/unstable/reactivity"; |
| 9 | import { useEffect } from "react"; | 9 | import { useEffect } from "react"; |
| 10 | 10 | ||
| 11 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 11 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 12 | import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient"; | 12 | import { |
| 13 | 13 | readEnvironmentConnection, | |
| 14 | export type GitStatusStreamError = GitManagerServiceError; | 14 | subscribeEnvironmentConnections, |
| 15 | } from "../environments/runtime"; | ||
| 16 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | ||
| 15 | 17 | ||
| 16 | export interface GitStatusState { | 18 | interface GitStatusState { |
| 17 | readonly data: GitStatusResult | null; | 19 | readonly data: GitStatusResult | null; |
| 18 | readonly error: GitStatusStreamError | null; | 20 | readonly error: GitManagerServiceError | null; |
| 19 | readonly cause: Cause.Cause<GitStatusStreamError> | null; | 21 | readonly cause: Cause.Cause<GitManagerServiceError> | null; |
| 20 | readonly isPending: boolean; | 22 | readonly isPending: boolean; |
| 21 | } | 23 | } |
| 22 | 24 | ||
| 23 | type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">; | 25 | type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">; |
| 26 | interface ResolvedGitStatusClient { | ||
| 27 | readonly clientIdentity: string; | ||
| 28 | readonly client: GitStatusClient; | ||
| 29 | } | ||
| 24 | 30 | ||
| 25 | interface WatchedGitStatus { | 31 | interface WatchedGitStatus { |
| 26 | refCount: number; | 32 | refCount: number; |
| 27 | unsubscribe: () => void; | 33 | unsubscribe: () => void; |
| 28 | } | 34 | } |
| 29 | 35 | ||
| 30 | export interface GitStatusTarget { | 36 | interface GitStatusTarget { |
| 31 | readonly environmentId: EnvironmentId | null; | 37 | readonly environmentId: EnvironmentId | null; |
| 32 | readonly cwd: string | null; | 38 | readonly cwd: string | null; |
| 33 | } | 39 | } |
| ... | @@ -64,18 +70,21 @@ const gitStatusStateAtom = Atom.family((key: string) => { | ... | @@ -64,18 +70,21 @@ const gitStatusStateAtom = Atom.family((key: string) => { |
| 64 | }); | 70 | }); |
| 65 | 71 | ||
| 66 | function getGitStatusTargetKey(target: GitStatusTarget): string | null { | 72 | function getGitStatusTargetKey(target: GitStatusTarget): string | null { |
| 67 | if (target.cwd === null) { | 73 | if (target.environmentId === null || target.cwd === null) { |
| 68 | return null; | 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 { | 80 | function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatusClient | null { |
| 75 | if (target.environmentId) { | 81 | if (target.environmentId === null) { |
| 76 | return getWsRpcClientForEnvironment(target.environmentId).git; | 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 | export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState { | 90 | export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState { |
| ... | @@ -87,10 +96,7 @@ export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState { | ... | @@ -87,10 +96,7 @@ export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState { |
| 87 | return appAtomRegistry.get(gitStatusStateAtom(targetKey)); | 96 | return appAtomRegistry.get(gitStatusStateAtom(targetKey)); |
| 88 | } | 97 | } |
| 89 | 98 | ||
| 90 | export function watchGitStatus( | 99 | export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient): () => void { |
| 91 | target: GitStatusTarget, | ||
| 92 | client: GitStatusClient = resolveGitStatusClient(target), | ||
| 93 | ): () => void { | ||
| 94 | const targetKey = getGitStatusTargetKey(target); | 100 | const targetKey = getGitStatusTargetKey(target); |
| 95 | if (targetKey === null) { | 101 | if (targetKey === null) { |
| 96 | return NOOP; | 102 | return NOOP; |
| ... | @@ -104,7 +110,7 @@ export function watchGitStatus( | ... | @@ -104,7 +110,7 @@ export function watchGitStatus( |
| 104 | 110 | ||
| 105 | watchedGitStatuses.set(targetKey, { | 111 | watchedGitStatuses.set(targetKey, { |
| 106 | refCount: 1, | 112 | refCount: 1, |
| 107 | unsubscribe: subscribeToGitStatus(targetKey, target.cwd!, client), | 113 | unsubscribe: subscribeToGitStatusTarget(targetKey, target, client), |
| 108 | }); | 114 | }); |
| 109 | 115 | ||
| 110 | return () => unwatchGitStatus(targetKey); | 116 | return () => unwatchGitStatus(targetKey); |
| ... | @@ -112,13 +118,18 @@ export function watchGitStatus( | ... | @@ -112,13 +118,18 @@ export function watchGitStatus( |
| 112 | 118 | ||
| 113 | export function refreshGitStatus( | 119 | export function refreshGitStatus( |
| 114 | target: GitStatusTarget, | 120 | target: GitStatusTarget, |
| 115 | client: GitStatusClient = resolveGitStatusClient(target), | 121 | client?: GitStatusClient, |
| 116 | ): Promise<GitStatusResult | null> { | 122 | ): Promise<GitStatusResult | null> { |
| 117 | const targetKey = getGitStatusTargetKey(target); | 123 | const targetKey = getGitStatusTargetKey(target); |
| 118 | if (targetKey === null || target.cwd === null) { | 124 | if (targetKey === null || target.cwd === null) { |
| 119 | return Promise.resolve(null); | 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 | const currentInFlight = gitStatusRefreshInFlight.get(targetKey); | 133 | const currentInFlight = gitStatusRefreshInFlight.get(targetKey); |
| 123 | if (currentInFlight) { | 134 | if (currentInFlight) { |
| 124 | return currentInFlight; | 135 | return currentInFlight; |
| ... | @@ -130,7 +141,7 @@ export function refreshGitStatus( | ... | @@ -130,7 +141,7 @@ export function refreshGitStatus( |
| 130 | } | 141 | } |
| 131 | 142 | ||
| 132 | gitStatusLastRefreshAtByKey.set(targetKey, Date.now()); | 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 | gitStatusRefreshInFlight.delete(targetKey); | 145 | gitStatusRefreshInFlight.delete(targetKey); |
| 135 | }); | 146 | }); |
| 136 | gitStatusRefreshInFlight.set(targetKey, refreshPromise); | 147 | gitStatusRefreshInFlight.set(targetKey, refreshPromise); |
| ... | @@ -179,11 +190,62 @@ function unwatchGitStatus(targetKey: string): void { | ... | @@ -179,11 +190,62 @@ function unwatchGitStatus(targetKey: string): void { |
| 179 | watchedGitStatuses.delete(targetKey); | 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 | function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void { | 244 | function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void { |
| 183 | markGitStatusPending(targetKey); | 245 | markGitStatusPending(targetKey); |
| 184 | return client.onStatus( | 246 | return client.onStatus( |
| 185 | { cwd }, | 247 | { cwd }, |
| 186 | (status) => { | 248 | (status: GitStatusResult) => { |
| 187 | appAtomRegistry.set(gitStatusStateAtom(targetKey), { | 249 | appAtomRegistry.set(gitStatusStateAtom(targetKey), { |
| 188 | data: status, | 250 | data: status, |
| 189 | error: null, | 251 | error: null, |
apps/web/src/lib/utils.test.ts+1-42| ... | @@ -1,15 +1,5 @@ | ... | @@ -1,15 +1,5 @@ |
| 1 | import { assert, describe, expect, it, vi } from "vitest"; | 1 | import { describe, assert, it } 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 | |||
| 11 | import { isWindowsPlatform } from "./utils"; | 2 | import { isWindowsPlatform } from "./utils"; |
| 12 | import { resolveServerUrl } from "./utils"; | ||
| 13 | 3 | ||
| 14 | describe("isWindowsPlatform", () => { | 4 | describe("isWindowsPlatform", () => { |
| 15 | it("matches Windows platform identifiers", () => { | 5 | it("matches Windows platform identifiers", () => { |
| ... | @@ -22,34 +12,3 @@ describe("isWindowsPlatform", () => { | ... | @@ -22,34 +12,3 @@ describe("isWindowsPlatform", () => { |
| 22 | assert.isFalse(isWindowsPlatform("darwin")); | 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,10 +1,8 @@ |
| 1 | import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { String, Predicate } from "effect"; | ||
| 3 | import { type CxOptions, cx } from "class-variance-authority"; | 2 | import { type CxOptions, cx } from "class-variance-authority"; |
| 4 | import { twMerge } from "tailwind-merge"; | 3 | import { twMerge } from "tailwind-merge"; |
| 5 | import * as Random from "effect/Random"; | 4 | import * as Random from "effect/Random"; |
| 6 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 7 | import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap"; | ||
| 8 | import { DraftId } from "../composerDraftStore"; | 6 | import { DraftId } from "../composerDraftStore"; |
| 9 | 7 | ||
| 10 | export function cn(...inputs: CxOptions) { | 8 | export function cn(...inputs: CxOptions) { |
| ... | @@ -39,30 +37,3 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); | ... | @@ -39,30 +37,3 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); |
| 39 | export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID()); | 37 | export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID()); |
| 40 | 38 | ||
| 41 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); | 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,6 +79,7 @@ const rpcClientMock = { |
| 79 | updateSettings: vi.fn(), | 79 | updateSettings: vi.fn(), |
| 80 | subscribeConfig: vi.fn(), | 80 | subscribeConfig: vi.fn(), |
| 81 | subscribeLifecycle: vi.fn(), | 81 | subscribeLifecycle: vi.fn(), |
| 82 | subscribeAuthAccess: vi.fn(), | ||
| 82 | }, | 83 | }, |
| 83 | orchestration: { | 84 | orchestration: { |
| 84 | getSnapshot: vi.fn(), | 85 | getSnapshot: vi.fn(), |
| ... | @@ -92,23 +93,29 @@ const rpcClientMock = { | ... | @@ -92,23 +93,29 @@ const rpcClientMock = { |
| 92 | }, | 93 | }, |
| 93 | }; | 94 | }; |
| 94 | 95 | ||
| 95 | vi.mock("./wsRpcClient", () => { | 96 | vi.mock("./environments/runtime", () => ({ |
| 96 | return { | 97 | getPrimaryEnvironmentConnection: () => ({ |
| 97 | getWsRpcClient: () => rpcClientMock, | 98 | kind: "primary" as const, |
| 98 | getPrimaryWsRpcClientEntry: () => ({ | 99 | knownEnvironment: { |
| 99 | key: "primary", | 100 | id: "environment-local", |
| 100 | knownEnvironment: { | 101 | label: "Primary", |
| 101 | id: "primary", | 102 | source: "manual" as const, |
| 102 | label: "Primary", | 103 | target: { |
| 103 | source: "manual", | 104 | httpBaseUrl: "http://localhost:3000", |
| 104 | target: { type: "ws", wsUrl: "ws://localhost:3000" }, | 105 | wsBaseUrl: "ws://localhost:3000", |
| 105 | }, | 106 | }, |
| 106 | client: rpcClientMock, | 107 | environmentId: EnvironmentId.makeUnsafe("environment-local"), |
| 107 | environmentId: null, | 108 | }, |
| 108 | }), | 109 | client: rpcClientMock, |
| 109 | __resetWsRpcClientForTests: vi.fn(), | 110 | environmentId: EnvironmentId.makeUnsafe("environment-local"), |
| 110 | }; | 111 | ensureBootstrapped: async () => undefined, |
| 111 | }); | 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 | vi.mock("./contextMenuFallback", () => ({ | 120 | vi.mock("./contextMenuFallback", () => ({ |
| 114 | showContextMenuFallback: showContextMenuFallbackMock, | 121 | showContextMenuFallback: showContextMenuFallbackMock, |
| ... | @@ -132,8 +139,17 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn | ... | @@ -132,8 +139,17 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn |
| 132 | 139 | ||
| 133 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { | 140 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { |
| 134 | return { | 141 | return { |
| 135 | getWsUrl: () => null, | ||
| 136 | getLocalEnvironmentBootstrap: () => null, | 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 | pickFolder: async () => null, | 153 | pickFolder: async () => null, |
| 138 | confirm: async () => true, | 154 | confirm: async () => true, |
| 139 | setTheme: async () => undefined, | 155 | setTheme: async () => undefined, |
| ... | @@ -185,6 +201,12 @@ const baseEnvironment = { | ... | @@ -185,6 +201,12 @@ const baseEnvironment = { |
| 185 | 201 | ||
| 186 | const baseServerConfig: ServerConfig = { | 202 | const baseServerConfig: ServerConfig = { |
| 187 | environment: baseEnvironment, | 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 | cwd: "/tmp/workspace", | 210 | cwd: "/tmp/workspace", |
| 189 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", | 211 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", |
| 190 | keybindings: [], | 212 | keybindings: [], |
| ... | @@ -232,7 +254,7 @@ describe("wsApi", () => { | ... | @@ -232,7 +254,7 @@ describe("wsApi", () => { |
| 232 | rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); | 254 | rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); |
| 233 | const { createLocalApi } = await import("./localApi"); | 255 | const { createLocalApi } = await import("./localApi"); |
| 234 | 256 | ||
| 235 | const api = createLocalApi(); | 257 | const api = createLocalApi(rpcClientMock as never); |
| 236 | 258 | ||
| 237 | await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); | 259 | await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); |
| 238 | expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); | 260 | expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); |
| ... | @@ -395,7 +417,7 @@ describe("wsApi", () => { | ... | @@ -395,7 +417,7 @@ describe("wsApi", () => { |
| 395 | rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); | 417 | rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); |
| 396 | const { createLocalApi } = await import("./localApi"); | 418 | const { createLocalApi } = await import("./localApi"); |
| 397 | 419 | ||
| 398 | const api = createLocalApi(); | 420 | const api = createLocalApi(rpcClientMock as never); |
| 399 | 421 | ||
| 400 | await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); | 422 | await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); |
| 401 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); | 423 | expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); |
| ... | @@ -409,7 +431,7 @@ describe("wsApi", () => { | ... | @@ -409,7 +431,7 @@ describe("wsApi", () => { |
| 409 | rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); | 431 | rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); |
| 410 | const { createLocalApi } = await import("./localApi"); | 432 | const { createLocalApi } = await import("./localApi"); |
| 411 | 433 | ||
| 412 | const api = createLocalApi(); | 434 | const api = createLocalApi(rpcClientMock as never); |
| 413 | 435 | ||
| 414 | await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( | 436 | await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( |
| 415 | nextSettings, | 437 | nextSettings, |
| ... | @@ -424,7 +446,7 @@ describe("wsApi", () => { | ... | @@ -424,7 +446,7 @@ describe("wsApi", () => { |
| 424 | getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); | 446 | getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); |
| 425 | 447 | ||
| 426 | const { createLocalApi } = await import("./localApi"); | 448 | const { createLocalApi } = await import("./localApi"); |
| 427 | const api = createLocalApi(); | 449 | const api = createLocalApi(rpcClientMock as never); |
| 428 | const items = [{ id: "delete", label: "Delete" }] as const; | 450 | const items = [{ id: "delete", label: "Delete" }] as const; |
| 429 | 451 | ||
| 430 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); | 452 | await expect(api.contextMenu.show(items)).resolves.toBe("delete"); |
| ... | @@ -435,7 +457,7 @@ describe("wsApi", () => { | ... | @@ -435,7 +457,7 @@ describe("wsApi", () => { |
| 435 | showContextMenuFallbackMock.mockResolvedValue("rename"); | 457 | showContextMenuFallbackMock.mockResolvedValue("rename"); |
| 436 | const { createLocalApi } = await import("./localApi"); | 458 | const { createLocalApi } = await import("./localApi"); |
| 437 | 459 | ||
| 438 | const api = createLocalApi(); | 460 | const api = createLocalApi(rpcClientMock as never); |
| 439 | const items = [{ id: "rename", label: "Rename" }] as const; | 461 | const items = [{ id: "rename", label: "Rename" }] as const; |
| 440 | 462 | ||
| 441 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); | 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,19 +1,23 @@ |
| 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; | 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { resetGitStatusStateForTests } from "./lib/gitStatusState"; | 3 | import { resetGitStatusStateForTests } from "./lib/gitStatusState"; |
| 4 | |||
| 5 | import { __resetWsRpcAtomClientForTests } from "./rpc/client"; | ||
| 6 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; | 4 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; |
| 7 | import { resetServerStateForTests } from "./rpc/serverState"; | 5 | import { resetServerStateForTests } from "./rpc/serverState"; |
| 8 | import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; | 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 | import { showContextMenuFallback } from "./contextMenuFallback"; | 16 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 11 | 17 | ||
| 12 | let cachedApi: LocalApi | undefined; | 18 | let cachedApi: LocalApi | undefined; |
| 13 | 19 | ||
| 14 | export function createLocalApi( | 20 | export function createLocalApi(rpcClient: WsRpcClient): LocalApi { |
| 15 | rpcClient: WsRpcClient = getPrimaryWsRpcClientEntry().client, | ||
| 16 | ): LocalApi { | ||
| 17 | return { | 21 | return { |
| 18 | dialogs: { | 22 | dialogs: { |
| 19 | pickFolder: async () => { | 23 | pickFolder: async () => { |
| ... | @@ -71,7 +75,7 @@ export function readLocalApi(): LocalApi | undefined { | ... | @@ -71,7 +75,7 @@ export function readLocalApi(): LocalApi | undefined { |
| 71 | return cachedApi; | 75 | return cachedApi; |
| 72 | } | 76 | } |
| 73 | 77 | ||
| 74 | cachedApi = createLocalApi(); | 78 | cachedApi = createLocalApi(getPrimaryEnvironmentConnection().client); |
| 75 | return cachedApi; | 79 | return cachedApi; |
| 76 | } | 80 | } |
| 77 | 81 | ||
| ... | @@ -85,10 +89,11 @@ export function ensureLocalApi(): LocalApi { | ... | @@ -85,10 +89,11 @@ export function ensureLocalApi(): LocalApi { |
| 85 | 89 | ||
| 86 | export async function __resetLocalApiForTests() { | 90 | export async function __resetLocalApiForTests() { |
| 87 | cachedApi = undefined; | 91 | cachedApi = undefined; |
| 88 | await __resetWsRpcAtomClientForTests(); | 92 | await resetEnvironmentServiceForTests(); |
| 89 | await __resetWsRpcClientForTests(); | ||
| 90 | resetGitStatusStateForTests(); | 93 | resetGitStatusStateForTests(); |
| 91 | resetRequestLatencyStateForTests(); | 94 | resetRequestLatencyStateForTests(); |
| 95 | resetSavedEnvironmentRegistryStoreForTests(); | ||
| 96 | resetSavedEnvironmentRuntimeStoreForTests(); | ||
| 92 | resetServerStateForTests(); | 97 | resetServerStateForTests(); |
| 93 | resetWsConnectionStateForTests(); | 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,8 +2,8 @@ import { Exit, Layer, ManagedRuntime, Scope, Tracer } from "effect"; |
| 2 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; | 2 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; |
| 3 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | 3 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 4 | 4 | ||
| 5 | import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; | ||
| 5 | import { isElectron } from "../env"; | 6 | import { isElectron } from "../env"; |
| 6 | import { resolveServerUrl } from "../lib/utils"; | ||
| 7 | import { APP_VERSION } from "~/branding"; | 7 | import { APP_VERSION } from "~/branding"; |
| 8 | 8 | ||
| 9 | const DEFAULT_EXPORT_INTERVAL_MS = 1_000; | 9 | const DEFAULT_EXPORT_INTERVAL_MS = 1_000; |
| ... | @@ -51,10 +51,7 @@ export function configureClientTracing(config: ClientTracingConfig = {}): Promis | ... | @@ -51,10 +51,7 @@ export function configureClientTracing(config: ClientTracingConfig = {}): Promis |
| 51 | } | 51 | } |
| 52 | 52 | ||
| 53 | async function applyClientTracingConfig(config: ClientTracingConfig): Promise<void> { | 53 | async function applyClientTracingConfig(config: ClientTracingConfig): Promise<void> { |
| 54 | const otlpTracesUrl = resolveServerUrl({ | 54 | const otlpTracesUrl = resolvePrimaryEnvironmentHttpUrl("/api/observability/v1/traces"); |
| 55 | protocol: window.location.protocol === "https:" ? "https" : "http", | ||
| 56 | pathname: "/api/observability/v1/traces", | ||
| 57 | }); | ||
| 58 | const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS); | 55 | const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS); |
| 59 | const nextConfigKey = `${otlpTracesUrl}|${exportIntervalMs}`; | 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,9 +10,11 @@ |
| 10 | 10 | ||
| 11 | import { Route as rootRouteImport } from './routes/__root' | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as SettingsRouteImport } from './routes/settings' | 12 | import { Route as SettingsRouteImport } from './routes/settings' |
| 13 | import { Route as PairRouteImport } from './routes/pair' | ||
| 13 | import { Route as ChatRouteImport } from './routes/_chat' | 14 | import { Route as ChatRouteImport } from './routes/_chat' |
| 14 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | 15 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 15 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' | 16 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 17 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' | ||
| 16 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' | 18 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| 17 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' | 19 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' |
| 18 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' | 20 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' |
| ... | @@ -22,6 +24,11 @@ const SettingsRoute = SettingsRouteImport.update({ | ... | @@ -22,6 +24,11 @@ const SettingsRoute = SettingsRouteImport.update({ |
| 22 | path: '/settings', | 24 | path: '/settings', |
| 23 | getParentRoute: () => rootRouteImport, | 25 | getParentRoute: () => rootRouteImport, |
| 24 | } as any) | 26 | } as any) |
| 27 | const PairRoute = PairRouteImport.update({ | ||
| 28 | id: '/pair', | ||
| 29 | path: '/pair', | ||
| 30 | getParentRoute: () => rootRouteImport, | ||
| 31 | } as any) | ||
| 25 | const ChatRoute = ChatRouteImport.update({ | 32 | const ChatRoute = ChatRouteImport.update({ |
| 26 | id: '/_chat', | 33 | id: '/_chat', |
| 27 | getParentRoute: () => rootRouteImport, | 34 | getParentRoute: () => rootRouteImport, |
| ... | @@ -36,6 +43,11 @@ const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ | ... | @@ -36,6 +43,11 @@ const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ |
| 36 | path: '/general', | 43 | path: '/general', |
| 37 | getParentRoute: () => SettingsRoute, | 44 | getParentRoute: () => SettingsRoute, |
| 38 | } as any) | 45 | } as any) |
| 46 | const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ | ||
| 47 | id: '/connections', | ||
| 48 | path: '/connections', | ||
| 49 | getParentRoute: () => SettingsRoute, | ||
| 50 | } as any) | ||
| 39 | const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ | 51 | const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ |
| 40 | id: '/archived', | 52 | id: '/archived', |
| 41 | path: '/archived', | 53 | path: '/archived', |
| ... | @@ -55,15 +67,19 @@ const ChatEnvironmentIdThreadIdRoute = | ... | @@ -55,15 +67,19 @@ const ChatEnvironmentIdThreadIdRoute = |
| 55 | 67 | ||
| 56 | export interface FileRoutesByFullPath { | 68 | export interface FileRoutesByFullPath { |
| 57 | '/': typeof ChatIndexRoute | 69 | '/': typeof ChatIndexRoute |
| 70 | '/pair': typeof PairRoute | ||
| 58 | '/settings': typeof SettingsRouteWithChildren | 71 | '/settings': typeof SettingsRouteWithChildren |
| 59 | '/settings/archived': typeof SettingsArchivedRoute | 72 | '/settings/archived': typeof SettingsArchivedRoute |
| 73 | '/settings/connections': typeof SettingsConnectionsRoute | ||
| 60 | '/settings/general': typeof SettingsGeneralRoute | 74 | '/settings/general': typeof SettingsGeneralRoute |
| 61 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 75 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 62 | '/draft/$draftId': typeof ChatDraftDraftIdRoute | 76 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| 63 | } | 77 | } |
| 64 | export interface FileRoutesByTo { | 78 | export interface FileRoutesByTo { |
| 79 | '/pair': typeof PairRoute | ||
| 65 | '/settings': typeof SettingsRouteWithChildren | 80 | '/settings': typeof SettingsRouteWithChildren |
| 66 | '/settings/archived': typeof SettingsArchivedRoute | 81 | '/settings/archived': typeof SettingsArchivedRoute |
| 82 | '/settings/connections': typeof SettingsConnectionsRoute | ||
| 67 | '/settings/general': typeof SettingsGeneralRoute | 83 | '/settings/general': typeof SettingsGeneralRoute |
| 68 | '/': typeof ChatIndexRoute | 84 | '/': typeof ChatIndexRoute |
| 69 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 85 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| ... | @@ -72,8 +88,10 @@ export interface FileRoutesByTo { | ... | @@ -72,8 +88,10 @@ export interface FileRoutesByTo { |
| 72 | export interface FileRoutesById { | 88 | export interface FileRoutesById { |
| 73 | __root__: typeof rootRouteImport | 89 | __root__: typeof rootRouteImport |
| 74 | '/_chat': typeof ChatRouteWithChildren | 90 | '/_chat': typeof ChatRouteWithChildren |
| 91 | '/pair': typeof PairRoute | ||
| 75 | '/settings': typeof SettingsRouteWithChildren | 92 | '/settings': typeof SettingsRouteWithChildren |
| 76 | '/settings/archived': typeof SettingsArchivedRoute | 93 | '/settings/archived': typeof SettingsArchivedRoute |
| 94 | '/settings/connections': typeof SettingsConnectionsRoute | ||
| 77 | '/settings/general': typeof SettingsGeneralRoute | 95 | '/settings/general': typeof SettingsGeneralRoute |
| 78 | '/_chat/': typeof ChatIndexRoute | 96 | '/_chat/': typeof ChatIndexRoute |
| 79 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 97 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| ... | @@ -83,15 +101,19 @@ export interface FileRouteTypes { | ... | @@ -83,15 +101,19 @@ export interface FileRouteTypes { |
| 83 | fileRoutesByFullPath: FileRoutesByFullPath | 101 | fileRoutesByFullPath: FileRoutesByFullPath |
| 84 | fullPaths: | 102 | fullPaths: |
| 85 | | '/' | 103 | | '/' |
| 104 | | '/pair' | ||
| 86 | | '/settings' | 105 | | '/settings' |
| 87 | | '/settings/archived' | 106 | | '/settings/archived' |
| 107 | | '/settings/connections' | ||
| 88 | | '/settings/general' | 108 | | '/settings/general' |
| 89 | | '/$environmentId/$threadId' | 109 | | '/$environmentId/$threadId' |
| 90 | | '/draft/$draftId' | 110 | | '/draft/$draftId' |
| 91 | fileRoutesByTo: FileRoutesByTo | 111 | fileRoutesByTo: FileRoutesByTo |
| 92 | to: | 112 | to: |
| 113 | | '/pair' | ||
| 93 | | '/settings' | 114 | | '/settings' |
| 94 | | '/settings/archived' | 115 | | '/settings/archived' |
| 116 | | '/settings/connections' | ||
| 95 | | '/settings/general' | 117 | | '/settings/general' |
| 96 | | '/' | 118 | | '/' |
| 97 | | '/$environmentId/$threadId' | 119 | | '/$environmentId/$threadId' |
| ... | @@ -99,8 +121,10 @@ export interface FileRouteTypes { | ... | @@ -99,8 +121,10 @@ export interface FileRouteTypes { |
| 99 | id: | 121 | id: |
| 100 | | '__root__' | 122 | | '__root__' |
| 101 | | '/_chat' | 123 | | '/_chat' |
| 124 | | '/pair' | ||
| 102 | | '/settings' | 125 | | '/settings' |
| 103 | | '/settings/archived' | 126 | | '/settings/archived' |
| 127 | | '/settings/connections' | ||
| 104 | | '/settings/general' | 128 | | '/settings/general' |
| 105 | | '/_chat/' | 129 | | '/_chat/' |
| 106 | | '/_chat/$environmentId/$threadId' | 130 | | '/_chat/$environmentId/$threadId' |
| ... | @@ -109,6 +133,7 @@ export interface FileRouteTypes { | ... | @@ -109,6 +133,7 @@ export interface FileRouteTypes { |
| 109 | } | 133 | } |
| 110 | export interface RootRouteChildren { | 134 | export interface RootRouteChildren { |
| 111 | ChatRoute: typeof ChatRouteWithChildren | 135 | ChatRoute: typeof ChatRouteWithChildren |
| 136 | PairRoute: typeof PairRoute | ||
| 112 | SettingsRoute: typeof SettingsRouteWithChildren | 137 | SettingsRoute: typeof SettingsRouteWithChildren |
| 113 | } | 138 | } |
| 114 | 139 | ||
| ... | @@ -121,6 +146,13 @@ declare module '@tanstack/react-router' { | ... | @@ -121,6 +146,13 @@ declare module '@tanstack/react-router' { |
| 121 | preLoaderRoute: typeof SettingsRouteImport | 146 | preLoaderRoute: typeof SettingsRouteImport |
| 122 | parentRoute: typeof rootRouteImport | 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 | '/_chat': { | 156 | '/_chat': { |
| 125 | id: '/_chat' | 157 | id: '/_chat' |
| 126 | path: '' | 158 | path: '' |
| ... | @@ -142,6 +174,13 @@ declare module '@tanstack/react-router' { | ... | @@ -142,6 +174,13 @@ declare module '@tanstack/react-router' { |
| 142 | preLoaderRoute: typeof SettingsGeneralRouteImport | 174 | preLoaderRoute: typeof SettingsGeneralRouteImport |
| 143 | parentRoute: typeof SettingsRoute | 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 | '/settings/archived': { | 184 | '/settings/archived': { |
| 146 | id: '/settings/archived' | 185 | id: '/settings/archived' |
| 147 | path: '/archived' | 186 | path: '/archived' |
| ... | @@ -182,11 +221,13 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) | ... | @@ -182,11 +221,13 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) |
| 182 | 221 | ||
| 183 | interface SettingsRouteChildren { | 222 | interface SettingsRouteChildren { |
| 184 | SettingsArchivedRoute: typeof SettingsArchivedRoute | 223 | SettingsArchivedRoute: typeof SettingsArchivedRoute |
| 224 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute | ||
| 185 | SettingsGeneralRoute: typeof SettingsGeneralRoute | 225 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 186 | } | 226 | } |
| 187 | 227 | ||
| 188 | const SettingsRouteChildren: SettingsRouteChildren = { | 228 | const SettingsRouteChildren: SettingsRouteChildren = { |
| 189 | SettingsArchivedRoute: SettingsArchivedRoute, | 229 | SettingsArchivedRoute: SettingsArchivedRoute, |
| 230 | SettingsConnectionsRoute: SettingsConnectionsRoute, | ||
| 190 | SettingsGeneralRoute: SettingsGeneralRoute, | 231 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 191 | } | 232 | } |
| 192 | 233 | ||
| ... | @@ -196,6 +237,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( | ... | @@ -196,6 +237,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( |
| 196 | 237 | ||
| 197 | const rootRouteChildren: RootRouteChildren = { | 238 | const rootRouteChildren: RootRouteChildren = { |
| 198 | ChatRoute: ChatRouteWithChildren, | 239 | ChatRoute: ChatRouteWithChildren, |
| 240 | PairRoute: PairRoute, | ||
| 199 | SettingsRoute: SettingsRouteWithChildren, | 241 | SettingsRoute: SettingsRouteWithChildren, |
| 200 | } | 242 | } |
| 201 | export const routeTree = rootRouteImport | 243 | export const routeTree = rootRouteImport |
apps/web/src/routes/__root.tsx+70-486| ... | @@ -1,25 +1,14 @@ | ... | @@ -1,25 +1,14 @@ |
| 1 | import { | 1 | import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; |
| 2 | type EnvironmentId, | 2 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; |
| 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"; | ||
| 13 | import { | 3 | import { |
| 14 | Outlet, | 4 | Outlet, |
| 15 | createRootRouteWithContext, | 5 | createRootRouteWithContext, |
| 16 | type ErrorComponentProps, | 6 | type ErrorComponentProps, |
| 17 | useNavigate, | ||
| 18 | useLocation, | 7 | useLocation, |
| 8 | useNavigate, | ||
| 19 | } from "@tanstack/react-router"; | 9 | } from "@tanstack/react-router"; |
| 20 | import { useEffect, useEffectEvent, useRef, useState } from "react"; | 10 | import { useEffect, useEffectEvent, useRef } from "react"; |
| 21 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; | 11 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 22 | import { Throttler } from "@tanstack/react-pacer"; | ||
| 23 | 12 | ||
| 24 | import { APP_DISPLAY_NAME } from "../branding"; | 13 | import { APP_DISPLAY_NAME } from "../branding"; |
| 25 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; | 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| ... | @@ -40,38 +29,34 @@ import { | ... | @@ -40,38 +29,34 @@ import { |
| 40 | useServerConfigUpdatedSubscription, | 29 | useServerConfigUpdatedSubscription, |
| 41 | useServerWelcomeSubscription, | 30 | useServerWelcomeSubscription, |
| 42 | } from "../rpc/serverState"; | 31 | } from "../rpc/serverState"; |
| 43 | import { | 32 | import { useStore } from "../store"; |
| 44 | markPromotedDraftThreadByRef, | ||
| 45 | markPromotedDraftThreadsByRef, | ||
| 46 | useComposerDraftStore, | ||
| 47 | } from "../composerDraftStore"; | ||
| 48 | import { | ||
| 49 | selectProjectsAcrossEnvironments, | ||
| 50 | selectThreadsAcrossEnvironments, | ||
| 51 | useStore, | ||
| 52 | } from "../store"; | ||
| 53 | import { useUiStateStore } from "../uiStateStore"; | 33 | import { useUiStateStore } from "../uiStateStore"; |
| 54 | import { useTerminalStateStore } from "../terminalStateStore"; | ||
| 55 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; | 34 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; |
| 56 | import { providerQueryKeys } from "../lib/providerReactQuery"; | 35 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; |
| 57 | import { projectQueryKeys } from "../lib/projectReactQuery"; | 36 | import { |
| 58 | import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; | 37 | ensureEnvironmentConnectionBootstrapped, |
| 59 | import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; | 38 | getPrimaryEnvironmentConnection, |
| 60 | import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; | 39 | startEnvironmentConnectionService, |
| 61 | import { deriveReplayRetryDecision } from "../orchestrationRecovery"; | 40 | } from "../environments/runtime"; |
| 62 | import { selectThreadByRef } from "../store"; | 41 | import { configureClientTracing } from "../observability/clientTracing"; |
| 63 | import { | 42 | import { |
| 64 | bindPrimaryWsRpcClientEnvironment, | 43 | ensurePrimaryEnvironmentReady, |
| 65 | bindWsRpcClientEntryEnvironment, | 44 | resolveInitialServerAuthGateState, |
| 66 | getPrimaryWsRpcClientEntry, | 45 | updatePrimaryEnvironmentDescriptor, |
| 67 | listWsRpcClientEntries, | 46 | } from "../environments/primary"; |
| 68 | subscribeWsRpcClientRegistry, | ||
| 69 | type WsRpcClientEntry, | ||
| 70 | } from "~/wsRpcClient"; | ||
| 71 | 47 | ||
| 72 | export const Route = createRootRouteWithContext<{ | 48 | export const Route = createRootRouteWithContext<{ |
| 73 | queryClient: QueryClient; | 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 | component: RootRouteView, | 60 | component: RootRouteView, |
| 76 | errorComponent: RootRouteErrorView, | 61 | errorComponent: RootRouteErrorView, |
| 77 | head: () => ({ | 62 | head: () => ({ |
| ... | @@ -80,22 +65,31 @@ export const Route = createRootRouteWithContext<{ | ... | @@ -80,22 +65,31 @@ export const Route = createRootRouteWithContext<{ |
| 80 | }); | 65 | }); |
| 81 | 66 | ||
| 82 | function RootRouteView() { | 67 | function RootRouteView() { |
| 83 | if (!readLocalApi()) { | 68 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 84 | return ( | 69 | const { authGateState } = Route.useRouteContext(); |
| 85 | <div className="flex h-screen flex-col bg-background text-foreground"> | 70 | |
| 86 | <div className="flex flex-1 items-center justify-center"> | 71 | useEffect(() => { |
| 87 | <p className="text-sm text-muted-foreground"> | 72 | const frame = window.requestAnimationFrame(() => { |
| 88 | Connecting to {APP_DISPLAY_NAME} server... | 73 | syncBrowserChromeTheme(); |
| 89 | </p> | 74 | }); |
| 90 | </div> | 75 | return () => { |
| 91 | </div> | 76 | window.cancelAnimationFrame(frame); |
| 92 | ); | 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 | return ( | 87 | return ( |
| 96 | <ToastProvider> | 88 | <ToastProvider> |
| 97 | <AnchoredToastProvider> | 89 | <AnchoredToastProvider> |
| 90 | <AuthenticatedTracingBootstrap /> | ||
| 98 | <ServerStateBootstrap /> | 91 | <ServerStateBootstrap /> |
| 92 | <EnvironmentConnectionManagerBootstrap /> | ||
| 99 | <EventRouter /> | 93 | <EventRouter /> |
| 100 | <WebSocketConnectionCoordinator /> | 94 | <WebSocketConnectionCoordinator /> |
| 101 | <SlowRpcAckToastCoordinator /> | 95 | <SlowRpcAckToastCoordinator /> |
| ... | @@ -180,97 +174,48 @@ function errorDetails(error: unknown): string { | ... | @@ -180,97 +174,48 @@ function errorDetails(error: unknown): string { |
| 180 | } | 174 | } |
| 181 | } | 175 | } |
| 182 | 176 | ||
| 183 | function coalesceOrchestrationUiEvents( | 177 | function ServerStateBootstrap() { |
| 184 | events: ReadonlyArray<OrchestrationEvent>, | 178 | useEffect(() => startServerStateSync(getPrimaryEnvironmentConnection().client.server), []); |
| 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 | } | ||
| 216 | 179 | ||
| 217 | return coalesced; | 180 | return null; |
| 218 | } | 181 | } |
| 219 | 182 | ||
| 220 | const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; | 183 | function AuthenticatedTracingBootstrap() { |
| 221 | const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; | 184 | useEffect(() => { |
| 222 | 185 | void configureClientTracing(); | |
| 223 | function useRegisteredWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> { | 186 | }, []); |
| 224 | const [, setRevision] = useState(0); | ||
| 225 | |||
| 226 | useEffect(() => subscribeWsRpcClientRegistry(() => setRevision((value) => value + 1)), []); | ||
| 227 | 187 | ||
| 228 | const entries = listWsRpcClientEntries(); | 188 | return null; |
| 229 | return entries.length > 0 ? entries : [getPrimaryWsRpcClientEntry()]; | ||
| 230 | } | 189 | } |
| 231 | 190 | ||
| 232 | function ServerStateBootstrap() { | 191 | function EnvironmentConnectionManagerBootstrap() { |
| 233 | useEffect(() => startServerStateSync(getPrimaryWsRpcClientEntry().client.server), []); | 192 | const queryClient = useQueryClient(); |
| 193 | |||
| 194 | useEffect(() => { | ||
| 195 | return startEnvironmentConnectionService(queryClient); | ||
| 196 | }, [queryClient]); | ||
| 234 | 197 | ||
| 235 | return null; | 198 | return null; |
| 236 | } | 199 | } |
| 237 | 200 | ||
| 238 | function EventRouter() { | 201 | function EventRouter() { |
| 239 | const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); | ||
| 240 | const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); | 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 | const navigate = useNavigate(); | 203 | const navigate = useNavigate(); |
| 253 | const pathname = useLocation({ select: (loc) => loc.pathname }); | 204 | const pathname = useLocation({ select: (loc) => loc.pathname }); |
| 254 | const readPathname = useEffectEvent(() => pathname); | 205 | const readPathname = useEffectEvent(() => pathname); |
| 255 | const handledBootstrapThreadIdRef = useRef<string | null>(null); | 206 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 256 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); | 207 | const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); |
| 257 | const disposedRef = useRef(false); | 208 | const disposedRef = useRef(false); |
| 258 | const bootstrapFromSnapshotRef = useRef<(environmentId: EnvironmentId) => Promise<void>>( | ||
| 259 | async () => undefined, | ||
| 260 | ); | ||
| 261 | const schedulePendingDomainEventFlushRef = useRef<() => void>(() => undefined); | ||
| 262 | const serverConfig = useServerConfig(); | 209 | const serverConfig = useServerConfig(); |
| 263 | const clientEntries = useRegisteredWsRpcClientEntries(); | ||
| 264 | 210 | ||
| 265 | const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { | 211 | const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { |
| 266 | if (!payload) return; | 212 | if (!payload) return; |
| 267 | 213 | ||
| 268 | bindPrimaryWsRpcClientEnvironment(payload.environment.environmentId); | 214 | updatePrimaryEnvironmentDescriptor(payload.environment); |
| 269 | setActiveEnvironmentId(payload.environment.environmentId); | 215 | setActiveEnvironmentId(payload.environment.environmentId); |
| 270 | schedulePendingDomainEventFlushRef.current(); | ||
| 271 | migrateLocalSettingsToServer(); | 216 | migrateLocalSettingsToServer(); |
| 272 | void (async () => { | 217 | void (async () => { |
| 273 | await bootstrapFromSnapshotRef.current(payload.environment.environmentId); | 218 | await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); |
| 274 | if (disposedRef.current) { | 219 | if (disposedRef.current) { |
| 275 | return; | 220 | return; |
| 276 | } | 221 | } |
| ... | @@ -278,12 +223,14 @@ function EventRouter() { | ... | @@ -278,12 +223,14 @@ function EventRouter() { |
| 278 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { | 223 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { |
| 279 | return; | 224 | return; |
| 280 | } | 225 | } |
| 281 | setProjectExpanded( | 226 | useUiStateStore |
| 282 | scopedProjectKey( | 227 | .getState() |
| 283 | scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), | 228 | .setProjectExpanded( |
| 284 | ), | 229 | scopedProjectKey( |
| 285 | true, | 230 | scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), |
| 286 | ); | 231 | ), |
| 232 | true, | ||
| 233 | ); | ||
| 287 | 234 | ||
| 288 | if (readPathname() !== "/") { | 235 | if (readPathname() !== "/") { |
| 289 | return; | 236 | return; |
| ... | @@ -365,379 +312,16 @@ function EventRouter() { | ... | @@ -365,379 +312,16 @@ function EventRouter() { |
| 365 | return; | 312 | return; |
| 366 | } | 313 | } |
| 367 | 314 | ||
| 368 | bindPrimaryWsRpcClientEnvironment(serverConfig.environment.environmentId); | 315 | updatePrimaryEnvironmentDescriptor(serverConfig.environment); |
| 369 | setActiveEnvironmentId(serverConfig.environment.environmentId); | 316 | setActiveEnvironmentId(serverConfig.environment.environmentId); |
| 370 | schedulePendingDomainEventFlushRef.current(); | ||
| 371 | }, [serverConfig, setActiveEnvironmentId]); | 317 | }, [serverConfig, setActiveEnvironmentId]); |
| 372 | 318 | ||
| 373 | useEffect(() => { | 319 | useEffect(() => { |
| 374 | let disposed = false; | ||
| 375 | disposedRef.current = false; | 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 | return () => { | 321 | return () => { |
| 717 | disposed = true; | ||
| 718 | disposedRef.current = true; | 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 | }, [ | 324 | }, []); |
| 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 | ]); | ||
| 741 | 325 | ||
| 742 | useServerWelcomeSubscription(handleWelcome); | 326 | useServerWelcomeSubscription(handleWelcome); |
| 743 | useServerConfigUpdatedSubscription(handleServerConfigUpdated); | 327 | useServerConfigUpdatedSubscription(handleServerConfigUpdated); |
apps/web/src/routes/_chat.index.tsx+2-26| ... | @@ -1,33 +1,9 @@ | ... | @@ -1,33 +1,9 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | ||
| 3 | import { isElectron } from "../env"; | 3 | import { NoActiveThreadState } from "../components/NoActiveThreadState"; |
| 4 | import { SidebarTrigger } from "../components/ui/sidebar"; | ||
| 5 | 4 | ||
| 6 | function ChatIndexRouteView() { | 5 | function ChatIndexRouteView() { |
| 7 | return ( | 6 | return <NoActiveThreadState />; |
| 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 | ); | ||
| 31 | } | 7 | } |
| 32 | 8 | ||
| 33 | export const Route = createFileRoute("/_chat/")({ | 9 | export const Route = createFileRoute("/_chat/")({ |
apps/web/src/routes/_chat.tsx+14-1| ... | @@ -1,7 +1,11 @@ | ... | @@ -1,7 +1,11 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime"; | 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 | import { useEffect } from "react"; | 3 | import { useEffect } from "react"; |
| 4 | 4 | ||
| 5 | import { | ||
| 6 | ensurePrimaryEnvironmentReady, | ||
| 7 | resolveInitialServerAuthGateState, | ||
| 8 | } from "../environments/primary"; | ||
| 5 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 9 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 6 | import { isTerminalFocused } from "../lib/terminalFocus"; | 10 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 7 | import { resolveShortcutCommand } from "../keybindings"; | 11 | import { resolveShortcutCommand } from "../keybindings"; |
| ... | @@ -102,5 +106,14 @@ function ChatRouteLayout() { | ... | @@ -102,5 +106,14 @@ function ChatRouteLayout() { |
| 102 | } | 106 | } |
| 103 | 107 | ||
| 104 | export const Route = createFileRoute("/_chat")({ | 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 | component: ChatRouteLayout, | 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,6 +2,10 @@ import { RotateCcwIcon } from "lucide-react"; |
| 2 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; | 2 | import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; |
| 3 | import { useEffect, useState } from "react"; | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | ||
| 5 | import { | ||
| 6 | ensurePrimaryEnvironmentReady, | ||
| 7 | resolveInitialServerAuthGateState, | ||
| 8 | } from "../environments/primary"; | ||
| 5 | import { useSettingsRestore } from "../components/settings/SettingsPanels"; | 9 | import { useSettingsRestore } from "../components/settings/SettingsPanels"; |
| 6 | import { Button } from "../components/ui/button"; | 10 | import { Button } from "../components/ui/button"; |
| 7 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; | 11 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; |
| ... | @@ -83,7 +87,15 @@ function SettingsRouteLayout() { | ... | @@ -83,7 +87,15 @@ function SettingsRouteLayout() { |
| 83 | } | 87 | } |
| 84 | 88 | ||
| 85 | export const Route = createFileRoute("/settings")({ | 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 | if (location.pathname === "/settings") { | 99 | if (location.pathname === "/settings") { |
| 88 | throw redirect({ to: "/settings/general", replace: true }); | 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,7 +3,6 @@ import { Duration, Effect, Layer, Schedule } from "effect"; |
| 3 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | 3 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; |
| 4 | import * as Socket from "effect/unstable/socket/Socket"; | 4 | import * as Socket from "effect/unstable/socket/Socket"; |
| 5 | 5 | ||
| 6 | import { resolveServerUrl } from "../lib/utils"; | ||
| 7 | import { | 6 | import { |
| 8 | acknowledgeRpcRequest, | 7 | acknowledgeRpcRequest, |
| 9 | clearAllTrackedRpcRequests, | 8 | clearAllTrackedRpcRequests, |
| ... | @@ -18,44 +17,96 @@ import { | ... | @@ -18,44 +17,96 @@ import { |
| 18 | WS_RECONNECT_MAX_RETRIES, | 17 | WS_RECONNECT_MAX_RETRIES, |
| 19 | } from "./wsConnectionState"; | 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 | type RpcClientFactory = typeof makeWsRpcProtocolClient; | 28 | type RpcClientFactory = typeof makeWsRpcProtocolClient; |
| 24 | export type WsRpcProtocolClient = | 29 | export type WsRpcProtocolClient = |
| 25 | RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never; | 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 | const trackingWebSocketConstructorLayer = Layer.succeed( | 86 | const trackingWebSocketConstructorLayer = Layer.succeed( |
| 34 | Socket.WebSocketConstructor, | 87 | Socket.WebSocketConstructor, |
| 35 | (socketUrl, protocols) => { | 88 | (socketUrl, protocols) => { |
| 36 | recordWsConnectionAttempt(socketUrl); | 89 | lifecycle.onAttempt(socketUrl); |
| 37 | const socket = new globalThis.WebSocket(socketUrl, protocols); | 90 | const socket = new globalThis.WebSocket(socketUrl, protocols); |
| 38 | 91 | ||
| 39 | socket.addEventListener( | 92 | socket.addEventListener( |
| 40 | "open", | 93 | "open", |
| 41 | () => { | 94 | () => { |
| 42 | recordWsConnectionOpened(); | 95 | lifecycle.onOpen(); |
| 43 | }, | 96 | }, |
| 44 | { once: true }, | 97 | { once: true }, |
| 45 | ); | 98 | ); |
| 46 | socket.addEventListener( | 99 | socket.addEventListener( |
| 47 | "error", | 100 | "error", |
| 48 | () => { | 101 | () => { |
| 49 | clearAllTrackedRpcRequests(); | 102 | lifecycle.onError("Unable to connect to the T3 server WebSocket."); |
| 50 | recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); | ||
| 51 | }, | 103 | }, |
| 52 | { once: true }, | 104 | { once: true }, |
| 53 | ); | 105 | ); |
| 54 | socket.addEventListener( | 106 | socket.addEventListener( |
| 55 | "close", | 107 | "close", |
| 56 | (event) => { | 108 | (event) => { |
| 57 | clearAllTrackedRpcRequests(); | 109 | lifecycle.onClose({ |
| 58 | recordWsConnectionClosed({ | ||
| 59 | code: event.code, | 110 | code: event.code, |
| 60 | reason: event.reason, | 111 | reason: event.reason, |
| 61 | }); | 112 | }); |
apps/web/src/rpc/serverState.test.ts+6| ... | @@ -66,6 +66,12 @@ const baseEnvironment = { | ... | @@ -66,6 +66,12 @@ const baseEnvironment = { |
| 66 | 66 | ||
| 67 | const baseServerConfig: ServerConfig = { | 67 | const baseServerConfig: ServerConfig = { |
| 68 | environment: baseEnvironment, | 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 | cwd: "/tmp/workspace", | 75 | cwd: "/tmp/workspace", |
| 70 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", | 76 | keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", |
| 71 | keybindings: [], | 77 | keybindings: [], |
apps/web/src/rpc/serverState.ts+1-1| ... | @@ -13,7 +13,7 @@ import { | ... | @@ -13,7 +13,7 @@ import { |
| 13 | import { Atom } from "effect/unstable/reactivity"; | 13 | import { Atom } from "effect/unstable/reactivity"; |
| 14 | import { useCallback, useRef } from "react"; | 14 | import { useCallback, useRef } from "react"; |
| 15 | 15 | ||
| 16 | import type { WsRpcClient } from "../wsRpcClient"; | 16 | import type { WsRpcClient } from "./wsRpcClient"; |
| 17 | import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; | 17 | import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; |
| 18 | 18 | ||
| 19 | export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; | 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,21 +1,21 @@ |
| 1 | import { | 1 | import type { |
| 2 | type EnvironmentId, | 2 | EnvironmentId, |
| 3 | type MessageId, | 3 | MessageId, |
| 4 | type OrchestrationCheckpointSummary, | 4 | OrchestrationCheckpointSummary, |
| 5 | type OrchestrationEvent, | 5 | OrchestrationEvent, |
| 6 | type OrchestrationMessage, | 6 | OrchestrationMessage, |
| 7 | type OrchestrationProposedPlan, | 7 | OrchestrationProposedPlan, |
| 8 | type OrchestrationReadModel, | 8 | OrchestrationReadModel, |
| 9 | type OrchestrationSession, | 9 | OrchestrationSession, |
| 10 | type OrchestrationSessionStatus, | 10 | OrchestrationSessionStatus, |
| 11 | type OrchestrationThread, | 11 | OrchestrationThread, |
| 12 | type OrchestrationThreadActivity, | 12 | OrchestrationThreadActivity, |
| 13 | type ProjectId, | 13 | ProjectId, |
| 14 | type ProviderKind, | 14 | ProviderKind, |
| 15 | type ScopedProjectRef, | 15 | ScopedProjectRef, |
| 16 | type ScopedThreadRef, | 16 | ScopedThreadRef, |
| 17 | ThreadId, | 17 | ThreadId, |
| 18 | type TurnId, | 18 | TurnId, |
| 19 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 20 | import { resolveModelSlugForProvider } from "@t3tools/shared/model"; | 20 | import { resolveModelSlugForProvider } from "@t3tools/shared/model"; |
| 21 | import { create } from "zustand"; | 21 | import { create } from "zustand"; |
| ... | @@ -36,6 +36,7 @@ import { | ... | @@ -36,6 +36,7 @@ import { |
| 36 | type ThreadTurnState, | 36 | type ThreadTurnState, |
| 37 | type TurnDiffSummary, | 37 | type TurnDiffSummary, |
| 38 | } from "./types"; | 38 | } from "./types"; |
| 39 | import { resolveEnvironmentHttpUrl } from "./environments/runtime"; | ||
| 39 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; | 40 | import { sanitizeThreadErrorMessage } from "./rpc/transportError"; |
| 40 | 41 | ||
| 41 | export interface EnvironmentState { | 42 | export interface EnvironmentState { |
| ... | @@ -136,14 +137,17 @@ function mapSession(session: OrchestrationSession): ThreadSession { | ... | @@ -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 | const attachments = message.attachments?.map((attachment) => ({ | 141 | const attachments = message.attachments?.map((attachment) => ({ |
| 141 | type: "image" as const, | 142 | type: "image" as const, |
| 142 | id: attachment.id, | 143 | id: attachment.id, |
| 143 | name: attachment.name, | 144 | name: attachment.name, |
| 144 | mimeType: attachment.mimeType, | 145 | mimeType: attachment.mimeType, |
| 145 | sizeBytes: attachment.sizeBytes, | 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 | return { | 153 | return { |
| ... | @@ -212,7 +216,7 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T | ... | @@ -212,7 +216,7 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T |
| 212 | runtimeMode: thread.runtimeMode, | 216 | runtimeMode: thread.runtimeMode, |
| 213 | interactionMode: thread.interactionMode, | 217 | interactionMode: thread.interactionMode, |
| 214 | session: thread.session ? mapSession(thread.session) : null, | 218 | session: thread.session ? mapSession(thread.session) : null, |
| 215 | messages: thread.messages.map(mapMessage), | 219 | messages: thread.messages.map((message) => mapMessage(environmentId, message)), |
| 216 | proposedPlans: thread.proposedPlans.map(mapProposedPlan), | 220 | proposedPlans: thread.proposedPlans.map(mapProposedPlan), |
| 217 | error: sanitizeThreadErrorMessage(thread.session?.lastError), | 221 | error: sanitizeThreadErrorMessage(thread.session?.lastError), |
| 218 | createdAt: thread.createdAt, | 222 | createdAt: thread.createdAt, |
| ... | @@ -883,34 +887,6 @@ function toLegacyProvider(providerName: string | null): ProviderKind { | ... | @@ -883,34 +887,6 @@ function toLegacyProvider(providerName: string | null): ProviderKind { |
| 883 | return "codex"; | 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 | function attachmentPreviewRoutePath(attachmentId: string): string { | 890 | function attachmentPreviewRoutePath(attachmentId: string): string { |
| 915 | return `/attachments/${encodeURIComponent(attachmentId)}`; | 891 | return `/attachments/${encodeURIComponent(attachmentId)}`; |
| 916 | } | 892 | } |
| ... | @@ -1298,7 +1274,7 @@ function applyEnvironmentOrchestrationEvent( | ... | @@ -1298,7 +1274,7 @@ function applyEnvironmentOrchestrationEvent( |
| 1298 | 1274 | ||
| 1299 | case "thread.message-sent": | 1275 | case "thread.message-sent": |
| 1300 | return updateThreadState(state, event.payload.threadId, (thread) => { | 1276 | return updateThreadState(state, event.payload.threadId, (thread) => { |
| 1301 | const message = mapMessage({ | 1277 | const message = mapMessage(thread.environmentId, { |
| 1302 | id: event.payload.messageId, | 1278 | id: event.payload.messageId, |
| 1303 | role: event.payload.role, | 1279 | role: event.payload.role, |
| 1304 | text: event.payload.text, | 1280 | text: event.payload.text, |
| ... | @@ -1631,6 +1607,16 @@ export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { | ... | @@ -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 | export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { | 1620 | export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { |
| 1635 | return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => | 1621 | return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => |
| 1636 | environmentState.threadIds.flatMap((threadId) => { | 1622 | environmentState.threadIds.flatMap((threadId) => { |
| ... | @@ -1656,6 +1642,15 @@ export function selectSidebarThreadsForProjectRef( | ... | @@ -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 | export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { | 1654 | export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { |
| 1660 | return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete; | 1655 | return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete; |
| 1661 | } | 1656 | } |
apps/web/src/timestampFormat.test.ts+86-2| ... | @@ -1,6 +1,11 @@ | ... | @@ -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 | describe("getTimestampFormatOptions", () => { | 10 | describe("getTimestampFormatOptions", () => { |
| 6 | it("omits hour12 when locale formatting is requested", () => { | 11 | it("omits hour12 when locale formatting is requested", () => { |
| ... | @@ -28,3 +33,82 @@ describe("getTimestampFormatOptions", () => { | ... | @@ -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,3 +71,90 @@ export function formatRelativeTimeLabel(isoDate: string) { |
| 71 | const relative = formatRelativeTime(isoDate); | 71 | const relative = formatRelativeTime(isoDate); |
| 72 | return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; | 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,7 +405,7 @@ export const useUiStateStore = create<UiStateStore>((set) => ({ |
| 405 | 405 | ||
| 406 | useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); | 406 | useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); |
| 407 | 407 | ||
| 408 | if (typeof window !== "undefined") { | 408 | if (typeof window !== "undefined" && typeof window.addEventListener === "function") { |
| 409 | window.addEventListener("beforeunload", () => { | 409 | window.addEventListener("beforeunload", () => { |
| 410 | debouncedPersistState.flush(); | 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,6 +1,6 @@ |
| 1 | import { Effect, Exit, PubSub, Scope, Stream } from "effect"; | 1 | import { Effect, Exit, PubSub, Scope, Stream } from "effect"; |
| 2 | import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts"; | 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 | type RpcServerInstance = RpcServer.RpcServer<any>; | 5 | type RpcServerInstance = RpcServer.RpcServer<any>; |
| 6 | 6 | ||
| ... | @@ -113,6 +113,13 @@ export class BrowserWsRpcHarness { | ... | @@ -113,6 +113,13 @@ export class BrowserWsRpcHarness { |
| 113 | } | 113 | } |
| 114 | const messages = this.parser.decode(rawData); | 114 | const messages = this.parser.decode(rawData); |
| 115 | for (const message of messages) { | 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 | await Effect.runPromise(server.write(0, message as never)); | 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 +6,8 @@ import { defineConfig } from "vite"; |
| 6 | import pkg from "./package.json" with { type: "json" }; | 6 | import pkg from "./package.json" with { type: "json" }; |
| 7 | 7 | ||
| 8 | const port = Number(process.env.PORT ?? 5733); | 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 | const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); | 11 | const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); |
| 10 | 12 | ||
| 11 | const buildSourcemap = | 13 | const buildSourcemap = |
| ... | @@ -15,6 +17,29 @@ const buildSourcemap = | ... | @@ -15,6 +17,29 @@ const buildSourcemap = |
| 15 | ? "hidden" | 17 | ? "hidden" |
| 16 | : true; | 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 | export default defineConfig({ | 43 | export default defineConfig({ |
| 19 | plugins: [ | 44 | plugins: [ |
| 20 | tanstackRouter(), | 45 | tanstackRouter(), |
| ... | @@ -34,21 +59,36 @@ export default defineConfig({ | ... | @@ -34,21 +59,36 @@ export default defineConfig({ |
| 34 | }, | 59 | }, |
| 35 | define: { | 60 | define: { |
| 36 | // In dev mode, tell the web app where the WebSocket server lives | 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 | "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), | 63 | "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), |
| 39 | }, | 64 | }, |
| 40 | resolve: { | 65 | resolve: { |
| 41 | tsconfigPaths: true, | 66 | tsconfigPaths: true, |
| 42 | }, | 67 | }, |
| 43 | server: { | 68 | server: { |
| 69 | host, | ||
| 44 | port, | 70 | port, |
| 45 | strictPort: true, | 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 | hmr: { | 86 | hmr: { |
| 47 | // Explicit config so Vite's HMR WebSocket connects reliably | 87 | // Explicit config so Vite's HMR WebSocket connects reliably |
| 48 | // inside Electron's BrowserWindow. Vite 8 uses console.debug for | 88 | // inside Electron's BrowserWindow. Vite 8 uses console.debug for |
| 49 | // connection logs — enable "Verbose" in DevTools to see them. | 89 | // connection logs — enable "Verbose" in DevTools to see them. |
| 50 | protocol: "ws", | 90 | protocol: "ws", |
| 51 | host: "localhost", | 91 | host, |
| 52 | }, | 92 | }, |
| 53 | }, | 93 | }, |
| 54 | build: { | 94 | build: { |
packages/client-runtime/src/knownEnvironment.test.ts+35-6| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { createKnownEnvironmentFromWsUrl } from "./knownEnvironment"; | 4 | import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment"; |
| 5 | import { | 5 | import { |
| 6 | parseScopedProjectKey, | 6 | parseScopedProjectKey, |
| 7 | parseScopedThreadKey, | 7 | parseScopedThreadKey, |
| ... | @@ -13,22 +13,51 @@ import { | ... | @@ -13,22 +13,51 @@ import { |
| 13 | } from "./scoped"; | 13 | } from "./scoped"; |
| 14 | 14 | ||
| 15 | describe("known environment bootstrap helpers", () => { | 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 | expect( | 17 | expect( |
| 18 | createKnownEnvironmentFromWsUrl({ | 18 | createKnownEnvironment({ |
| 19 | label: "Remote environment", | 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 | ).toEqual({ | 25 | ).toEqual({ |
| 23 | id: "ws:Remote environment", | 26 | id: "ws:Remote environment", |
| 24 | label: "Remote environment", | 27 | label: "Remote environment", |
| 25 | source: "manual", | 28 | source: "manual", |
| 26 | target: { | 29 | target: { |
| 27 | type: "ws", | 30 | httpBaseUrl: "https://remote.example.com", |
| 28 | wsUrl: "wss://remote.example.com/ws", | 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 | describe("scoped refs", () => { | 63 | describe("scoped refs", () => { |
packages/client-runtime/src/knownEnvironment.ts+25-11| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | export interface KnownEnvironmentConnectionTarget { | 3 | export interface KnownEnvironmentConnectionTarget { |
| 4 | readonly type: "ws"; | 4 | readonly httpBaseUrl: string; |
| 5 | readonly wsUrl: string; | 5 | readonly wsBaseUrl: string; |
| 6 | } | 6 | } |
| 7 | 7 | ||
| 8 | export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin"; | 8 | export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin"; |
| ... | @@ -15,25 +15,39 @@ export interface KnownEnvironment { | ... | @@ -15,25 +15,39 @@ export interface KnownEnvironment { |
| 15 | readonly target: KnownEnvironmentConnectionTarget; | 15 | readonly target: KnownEnvironmentConnectionTarget; |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | export function createKnownEnvironmentFromWsUrl(input: { | 18 | export function createKnownEnvironment(input: { |
| 19 | readonly id?: string; | 19 | readonly id?: string; |
| 20 | readonly label: string; | 20 | readonly label: string; |
| 21 | readonly source?: KnownEnvironmentSource; | 21 | readonly source?: KnownEnvironmentSource; |
| 22 | readonly wsUrl: string; | 22 | readonly target: KnownEnvironmentConnectionTarget; |
| 23 | }): KnownEnvironment { | 23 | }): KnownEnvironment { |
| 24 | return { | 24 | return { |
| 25 | id: input.id ?? `ws:${input.label}`, | 25 | id: input.id ?? `ws:${input.label}`, |
| 26 | label: input.label, | 26 | label: input.label, |
| 27 | source: input.source ?? "manual", | 27 | source: input.source ?? "manual", |
| 28 | target: { | 28 | target: input.target, |
| 29 | type: "ws", | ||
| 30 | wsUrl: input.wsUrl, | ||
| 31 | }, | ||
| 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 | environment: KnownEnvironment | null | undefined, | 39 | environment: KnownEnvironment | null | undefined, |
| 37 | ): string | null { | 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,6 +29,8 @@ export const MessageId = makeEntityId("MessageId"); |
| 29 | export type MessageId = typeof MessageId.Type; | 29 | export type MessageId = typeof MessageId.Type; |
| 30 | export const TurnId = makeEntityId("TurnId"); | 30 | export const TurnId = makeEntityId("TurnId"); |
| 31 | export type TurnId = typeof TurnId.Type; | 31 | export type TurnId = typeof TurnId.Type; |
| 32 | export const AuthSessionId = makeEntityId("AuthSessionId"); | ||
| 33 | export type AuthSessionId = typeof AuthSessionId.Type; | ||
| 32 | 34 | ||
| 33 | export const ProviderItemId = makeEntityId("ProviderItemId"); | 35 | export const ProviderItemId = makeEntityId("ProviderItemId"); |
| 34 | export type ProviderItemId = typeof ProviderItemId.Type; | 36 | export type ProviderItemId = typeof ProviderItemId.Type; |
packages/contracts/src/index.ts+1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | export * from "./baseSchemas"; | 1 | export * from "./baseSchemas"; |
| 2 | export * from "./auth"; | ||
| 2 | export * from "./environment"; | 3 | export * from "./environment"; |
| 3 | export * from "./ipc"; | 4 | export * from "./ipc"; |
| 4 | export * from "./terminal"; | 5 | export * from "./terminal"; |
packages/contracts/src/ipc.ts+13-2| ... | @@ -107,12 +107,23 @@ export interface DesktopUpdateCheckResult { | ... | @@ -107,12 +107,23 @@ export interface DesktopUpdateCheckResult { |
| 107 | 107 | ||
| 108 | export interface DesktopEnvironmentBootstrap { | 108 | export interface DesktopEnvironmentBootstrap { |
| 109 | label: string; | 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 | export interface DesktopBridge { | 123 | export interface DesktopBridge { |
| 114 | getWsUrl: () => string | null; | ||
| 115 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; | 124 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |
| 125 | getServerExposureState: () => Promise<DesktopServerExposureState>; | ||
| 126 | setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>; | ||
| 116 | pickFolder: () => Promise<string | null>; | 127 | pickFolder: () => Promise<string | null>; |
| 117 | confirm: (message: string) => Promise<boolean>; | 128 | confirm: (message: string) => Promise<boolean>; |
| 118 | setTheme: (theme: DesktopTheme) => Promise<void>; | 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,6 +66,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, |
| 66 | 66 | ||
| 67 | export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = { | 67 | export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = { |
| 68 | codex: { | 68 | codex: { |
| 69 | "gpt-5-codex": "gpt-5.4", | ||
| 69 | "5.4": "gpt-5.4", | 70 | "5.4": "gpt-5.4", |
| 70 | "5.3": "gpt-5.3-codex", | 71 | "5.3": "gpt-5.3-codex", |
| 71 | "gpt-5.3": "gpt-5.3-codex", | 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,6 +3,7 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; | 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 4 | 4 | ||
| 5 | import { OpenError, OpenInEditorInput } from "./editor"; | 5 | import { OpenError, OpenInEditorInput } from "./editor"; |
| 6 | import { AuthAccessStreamEvent } from "./auth"; | ||
| 6 | import { | 7 | import { |
| 7 | GitActionProgressEvent, | 8 | GitActionProgressEvent, |
| 8 | GitCheckoutInput, | 9 | GitCheckoutInput, |
| ... | @@ -118,6 +119,7 @@ export const WS_METHODS = { | ... | @@ -118,6 +119,7 @@ export const WS_METHODS = { |
| 118 | subscribeTerminalEvents: "subscribeTerminalEvents", | 119 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 119 | subscribeServerConfig: "subscribeServerConfig", | 120 | subscribeServerConfig: "subscribeServerConfig", |
| 120 | subscribeServerLifecycle: "subscribeServerLifecycle", | 121 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| 122 | subscribeAuthAccess: "subscribeAuthAccess", | ||
| 121 | } as const; | 123 | } as const; |
| 122 | 124 | ||
| 123 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { | 125 | export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { |
| ... | @@ -334,6 +336,12 @@ export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServer | ... | @@ -334,6 +336,12 @@ export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServer |
| 334 | stream: true, | 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 | export const WsRpcGroup = RpcGroup.make( | 345 | export const WsRpcGroup = RpcGroup.make( |
| 338 | WsServerGetConfigRpc, | 346 | WsServerGetConfigRpc, |
| 339 | WsServerRefreshProvidersRpc, | 347 | WsServerRefreshProvidersRpc, |
| ... | @@ -365,6 +373,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -365,6 +373,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 365 | WsSubscribeTerminalEventsRpc, | 373 | WsSubscribeTerminalEventsRpc, |
| 366 | WsSubscribeServerConfigRpc, | 374 | WsSubscribeServerConfigRpc, |
| 367 | WsSubscribeServerLifecycleRpc, | 375 | WsSubscribeServerLifecycleRpc, |
| 376 | WsSubscribeAuthAccessRpc, | ||
| 368 | WsOrchestrationGetSnapshotRpc, | 377 | WsOrchestrationGetSnapshotRpc, |
| 369 | WsOrchestrationDispatchCommandRpc, | 378 | WsOrchestrationDispatchCommandRpc, |
| 370 | WsOrchestrationGetTurnDiffRpc, | 379 | WsOrchestrationGetTurnDiffRpc, |
packages/contracts/src/server.ts+2| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { ExecutionEnvironmentDescriptor } from "./environment"; | 2 | import { ExecutionEnvironmentDescriptor } from "./environment"; |
| 3 | import { ServerAuthDescriptor } from "./auth"; | ||
| 3 | import { | 4 | import { |
| 4 | IsoDateTime, | 5 | IsoDateTime, |
| 5 | NonNegativeInt, | 6 | NonNegativeInt, |
| ... | @@ -85,6 +86,7 @@ export type ServerObservability = typeof ServerObservability.Type; | ... | @@ -85,6 +86,7 @@ export type ServerObservability = typeof ServerObservability.Type; |
| 85 | 86 | ||
| 86 | export const ServerConfig = Schema.Struct({ | 87 | export const ServerConfig = Schema.Struct({ |
| 87 | environment: ExecutionEnvironmentDescriptor, | 88 | environment: ExecutionEnvironmentDescriptor, |
| 89 | auth: ServerAuthDescriptor, | ||
| 88 | cwd: TrimmedNonEmptyString, | 90 | cwd: TrimmedNonEmptyString, |
| 89 | keybindingsConfigPath: TrimmedNonEmptyString, | 91 | keybindingsConfigPath: TrimmedNonEmptyString, |
| 90 | keybindings: ResolvedKeybindingsConfig, | 92 | keybindings: ResolvedKeybindingsConfig, |
packages/shared/src/model.test.ts+1| ... | @@ -48,6 +48,7 @@ const claudeCaps: ModelCapabilities = { | ... | @@ -48,6 +48,7 @@ const claudeCaps: ModelCapabilities = { |
| 48 | 48 | ||
| 49 | describe("normalizeModelSlug", () => { | 49 | describe("normalizeModelSlug", () => { |
| 50 | it("maps known aliases to canonical slugs", () => { | 50 | it("maps known aliases to canonical slugs", () => { |
| 51 | expect(normalizeModelSlug("gpt-5-codex")).toBe("gpt-5.4"); | ||
| 51 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); | 52 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); |
| 52 | expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); | 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,7 +54,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 54 | serverOffset: 0, | 54 | serverOffset: 0, |
| 55 | webOffset: 0, | 55 | webOffset: 0, |
| 56 | t3Home: undefined, | 56 | t3Home: undefined, |
| 57 | authToken: undefined, | ||
| 58 | noBrowser: undefined, | 57 | noBrowser: undefined, |
| 59 | autoBootstrapProjectFromCwd: undefined, | 58 | autoBootstrapProjectFromCwd: undefined, |
| 60 | logWebSocketEvents: undefined, | 59 | logWebSocketEvents: undefined, |
| ... | @@ -75,7 +74,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -75,7 +74,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 75 | serverOffset: 0, | 74 | serverOffset: 0, |
| 76 | webOffset: 0, | 75 | webOffset: 0, |
| 77 | t3Home: "/tmp/custom-t3", | 76 | t3Home: "/tmp/custom-t3", |
| 78 | authToken: "secret", | ||
| 79 | noBrowser: true, | 77 | noBrowser: true, |
| 80 | autoBootstrapProjectFromCwd: false, | 78 | autoBootstrapProjectFromCwd: false, |
| 81 | logWebSocketEvents: true, | 79 | logWebSocketEvents: true, |
| ... | @@ -105,7 +103,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -105,7 +103,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 105 | serverOffset: 0, | 103 | serverOffset: 0, |
| 106 | webOffset: 0, | 104 | webOffset: 0, |
| 107 | t3Home: undefined, | 105 | t3Home: undefined, |
| 108 | authToken: undefined, | ||
| 109 | noBrowser: undefined, | 106 | noBrowser: undefined, |
| 110 | autoBootstrapProjectFromCwd: undefined, | 107 | autoBootstrapProjectFromCwd: undefined, |
| 111 | logWebSocketEvents: undefined, | 108 | logWebSocketEvents: undefined, |
| ... | @@ -127,7 +124,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -127,7 +124,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 127 | serverOffset: 0, | 124 | serverOffset: 0, |
| 128 | webOffset: 0, | 125 | webOffset: 0, |
| 129 | t3Home: undefined, | 126 | t3Home: undefined, |
| 130 | authToken: undefined, | ||
| 131 | noBrowser: undefined, | 127 | noBrowser: undefined, |
| 132 | autoBootstrapProjectFromCwd: undefined, | 128 | autoBootstrapProjectFromCwd: undefined, |
| 133 | logWebSocketEvents: false, | 129 | logWebSocketEvents: false, |
| ... | @@ -148,7 +144,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -148,7 +144,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 148 | serverOffset: 0, | 144 | serverOffset: 0, |
| 149 | webOffset: 0, | 145 | webOffset: 0, |
| 150 | t3Home: "/tmp/my-t3", | 146 | t3Home: "/tmp/my-t3", |
| 151 | authToken: undefined, | ||
| 152 | noBrowser: undefined, | 147 | noBrowser: undefined, |
| 153 | autoBootstrapProjectFromCwd: undefined, | 148 | autoBootstrapProjectFromCwd: undefined, |
| 154 | logWebSocketEvents: undefined, | 149 | logWebSocketEvents: undefined, |
| ... | @@ -161,13 +156,12 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -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 | Effect.gen(function* () { | 160 | Effect.gen(function* () { |
| 166 | const env = yield* createDevRunnerEnv({ | 161 | const env = yield* createDevRunnerEnv({ |
| 167 | mode: "dev:desktop", | 162 | mode: "dev:desktop", |
| 168 | baseEnv: { | 163 | baseEnv: { |
| 169 | T3CODE_PORT: "3773", | 164 | T3CODE_PORT: "3773", |
| 170 | T3CODE_AUTH_TOKEN: "stale-token", | ||
| 171 | T3CODE_MODE: "web", | 165 | T3CODE_MODE: "web", |
| 172 | T3CODE_NO_BROWSER: "0", | 166 | T3CODE_NO_BROWSER: "0", |
| 173 | T3CODE_HOST: "0.0.0.0", | 167 | T3CODE_HOST: "0.0.0.0", |
| ... | @@ -176,7 +170,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -176,7 +170,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 176 | serverOffset: 0, | 170 | serverOffset: 0, |
| 177 | webOffset: 0, | 171 | webOffset: 0, |
| 178 | t3Home: "/tmp/my-t3", | 172 | t3Home: "/tmp/my-t3", |
| 179 | authToken: "fresh-token", | ||
| 180 | noBrowser: true, | 173 | noBrowser: true, |
| 181 | autoBootstrapProjectFromCwd: undefined, | 174 | autoBootstrapProjectFromCwd: undefined, |
| 182 | logWebSocketEvents: undefined, | 175 | logWebSocketEvents: undefined, |
| ... | @@ -187,14 +180,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -187,14 +180,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 187 | 180 | ||
| 188 | assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); | 181 | assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); |
| 189 | assert.equal(env.PORT, "5733"); | 182 | assert.equal(env.PORT, "5733"); |
| 190 | assert.equal(env.ELECTRON_RENDERER_PORT, "5733"); | 183 | assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); |
| 191 | assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:5733"); | 184 | assert.equal(env.HOST, "127.0.0.1"); |
| 192 | assert.equal(env.T3CODE_PORT, undefined); | 185 | assert.equal(env.T3CODE_PORT, "4222"); |
| 193 | assert.equal(env.T3CODE_AUTH_TOKEN, undefined); | ||
| 194 | assert.equal(env.T3CODE_MODE, undefined); | 186 | assert.equal(env.T3CODE_MODE, undefined); |
| 195 | assert.equal(env.T3CODE_NO_BROWSER, undefined); | 187 | assert.equal(env.T3CODE_NO_BROWSER, undefined); |
| 196 | assert.equal(env.T3CODE_HOST, undefined); | 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,6 +13,7 @@ const BASE_SERVER_PORT = 3773; |
| 13 | const BASE_WEB_PORT = 5733; | 13 | const BASE_WEB_PORT = 5733; |
| 14 | const MAX_HASH_OFFSET = 3000; | 14 | const MAX_HASH_OFFSET = 3000; |
| 15 | const MAX_PORT = 65535; | 15 | const MAX_PORT = 65535; |
| 16 | const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; | ||
| 16 | 17 | ||
| 17 | export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => | 18 | export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => |
| 18 | path.join(homedir(), ".t3"), | 19 | path.join(homedir(), ".t3"), |
| ... | @@ -120,7 +121,6 @@ interface CreateDevRunnerEnvInput { | ... | @@ -120,7 +121,6 @@ interface CreateDevRunnerEnvInput { |
| 120 | readonly serverOffset: number; | 121 | readonly serverOffset: number; |
| 121 | readonly webOffset: number; | 122 | readonly webOffset: number; |
| 122 | readonly t3Home: string | undefined; | 123 | readonly t3Home: string | undefined; |
| 123 | readonly authToken: string | undefined; | ||
| 124 | readonly noBrowser: boolean | undefined; | 124 | readonly noBrowser: boolean | undefined; |
| 125 | readonly autoBootstrapProjectFromCwd: boolean | undefined; | 125 | readonly autoBootstrapProjectFromCwd: boolean | undefined; |
| 126 | readonly logWebSocketEvents: boolean | undefined; | 126 | readonly logWebSocketEvents: boolean | undefined; |
| ... | @@ -135,7 +135,6 @@ export function createDevRunnerEnv({ | ... | @@ -135,7 +135,6 @@ export function createDevRunnerEnv({ |
| 135 | serverOffset, | 135 | serverOffset, |
| 136 | webOffset, | 136 | webOffset, |
| 137 | t3Home, | 137 | t3Home, |
| 138 | authToken, | ||
| 139 | noBrowser, | 138 | noBrowser, |
| 140 | autoBootstrapProjectFromCwd, | 139 | autoBootstrapProjectFromCwd, |
| 141 | logWebSocketEvents, | 140 | logWebSocketEvents, |
| ... | @@ -152,8 +151,9 @@ export function createDevRunnerEnv({ | ... | @@ -152,8 +151,9 @@ export function createDevRunnerEnv({ |
| 152 | const output: NodeJS.ProcessEnv = { | 151 | const output: NodeJS.ProcessEnv = { |
| 153 | ...baseEnv, | 152 | ...baseEnv, |
| 154 | PORT: String(webPort), | 153 | PORT: String(webPort), |
| 155 | ELECTRON_RENDERER_PORT: String(webPort), | 154 | VITE_DEV_SERVER_URL: |
| 156 | VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, | 155 | devUrl?.toString() ?? |
| 156 | `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, | ||
| 157 | T3CODE_HOME: resolvedBaseDir, | 157 | T3CODE_HOME: resolvedBaseDir, |
| 158 | }; | 158 | }; |
| 159 | 159 | ||
| ... | @@ -161,9 +161,8 @@ export function createDevRunnerEnv({ | ... | @@ -161,9 +161,8 @@ export function createDevRunnerEnv({ |
| 161 | output.T3CODE_PORT = String(serverPort); | 161 | output.T3CODE_PORT = String(serverPort); |
| 162 | output.VITE_WS_URL = `ws://localhost:${serverPort}`; | 162 | output.VITE_WS_URL = `ws://localhost:${serverPort}`; |
| 163 | } else { | 163 | } else { |
| 164 | delete output.T3CODE_PORT; | 164 | output.T3CODE_PORT = String(serverPort); |
| 165 | delete output.VITE_WS_URL; | 165 | output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; |
| 166 | delete output.T3CODE_AUTH_TOKEN; | ||
| 167 | delete output.T3CODE_MODE; | 166 | delete output.T3CODE_MODE; |
| 168 | delete output.T3CODE_NO_BROWSER; | 167 | delete output.T3CODE_NO_BROWSER; |
| 169 | delete output.T3CODE_HOST; | 168 | delete output.T3CODE_HOST; |
| ... | @@ -173,12 +172,6 @@ export function createDevRunnerEnv({ | ... | @@ -173,12 +172,6 @@ export function createDevRunnerEnv({ |
| 173 | output.T3CODE_HOST = host; | 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 | if (!isDesktopMode && noBrowser !== undefined) { | 175 | if (!isDesktopMode && noBrowser !== undefined) { |
| 183 | output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; | 176 | output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; |
| 184 | } else if (!isDesktopMode) { | 177 | } else if (!isDesktopMode) { |
| ... | @@ -208,6 +201,7 @@ export function createDevRunnerEnv({ | ... | @@ -208,6 +201,7 @@ export function createDevRunnerEnv({ |
| 208 | } | 201 | } |
| 209 | 202 | ||
| 210 | if (isDesktopMode) { | 203 | if (isDesktopMode) { |
| 204 | output.HOST = DESKTOP_DEV_LOOPBACK_HOST; | ||
| 211 | delete output.T3CODE_DESKTOP_WS_URL; | 205 | delete output.T3CODE_DESKTOP_WS_URL; |
| 212 | } | 206 | } |
| 213 | 207 | ||
| ... | @@ -350,7 +344,6 @@ export function resolveModePortOffsets<R = NetService>({ | ... | @@ -350,7 +344,6 @@ export function resolveModePortOffsets<R = NetService>({ |
| 350 | interface DevRunnerCliInput { | 344 | interface DevRunnerCliInput { |
| 351 | readonly mode: DevMode; | 345 | readonly mode: DevMode; |
| 352 | readonly t3Home: string | undefined; | 346 | readonly t3Home: string | undefined; |
| 353 | readonly authToken: string | undefined; | ||
| 354 | readonly noBrowser: boolean | undefined; | 347 | readonly noBrowser: boolean | undefined; |
| 355 | readonly autoBootstrapProjectFromCwd: boolean | undefined; | 348 | readonly autoBootstrapProjectFromCwd: boolean | undefined; |
| 356 | readonly logWebSocketEvents: boolean | undefined; | 349 | readonly logWebSocketEvents: boolean | undefined; |
| ... | @@ -430,7 +423,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { | ... | @@ -430,7 +423,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { |
| 430 | serverOffset, | 423 | serverOffset, |
| 431 | webOffset, | 424 | webOffset, |
| 432 | t3Home: input.t3Home, | 425 | t3Home: input.t3Home, |
| 433 | authToken: input.authToken, | ||
| 434 | noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), | 426 | noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), |
| 435 | autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride( | 427 | autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride( |
| 436 | input.autoBootstrapProjectFromCwd, | 428 | input.autoBootstrapProjectFromCwd, |
| ... | @@ -503,11 +495,6 @@ const devRunnerCli = Command.make("dev-runner", { | ... | @@ -503,11 +495,6 @@ const devRunnerCli = Command.make("dev-runner", { |
| 503 | Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), | 495 | Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), |
| 504 | Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")), | 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 | noBrowser: Flag.boolean("no-browser").pipe( | 498 | noBrowser: Flag.boolean("no-browser").pipe( |
| 512 | Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."), | 499 | Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."), |
| 513 | Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")), | 500 | Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")), |
turbo.json+1-1| ... | @@ -1,10 +1,10 @@ | ... | @@ -1,10 +1,10 @@ |
| 1 | { | 1 | { |
| 2 | "$schema": "https://turbo.build/schema.json", | 2 | "$schema": "https://turbo.build/schema.json", |
| 3 | "globalEnv": [ | 3 | "globalEnv": [ |
| 4 | "HOST", | ||
| 4 | "PORT", | 5 | "PORT", |
| 5 | "VITE_WS_URL", | 6 | "VITE_WS_URL", |
| 6 | "VITE_DEV_SERVER_URL", | 7 | "VITE_DEV_SERVER_URL", |
| 7 | "ELECTRON_RENDERER_PORT", | ||
| 8 | "T3CODE_LOG_WS_EVENTS", | 8 | "T3CODE_LOG_WS_EVENTS", |
| 9 | "T3CODE_MODE", | 9 | "T3CODE_MODE", |
| 10 | "T3CODE_PORT", | 10 | "T3CODE_PORT", |