authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-09 10:18:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 10:18:30-07:00
logb7559c467803f923e59a287c112b4c92b96e15c7
treee30c3661bb786525321d701fc1b601b6e5450742
parentb96308fc5adba6eecae5ee26efcfc8a01caaafd2
signature Signed by PGP key B5690EEEBB952194

Implement server auth bootstrap and pairing flow (#1768)

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
3This document describes the target architecture for first-class remote environments in T3 Code.
4
5It 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
24T3 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
26Remote 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
59The 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
65An `ExecutionEnvironment` is one running T3 server instance.
66
67It 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
77It is identified by a stable `environmentId`.
78
79This is the shared cross-client primitive. Desktop, mobile, and web should all reason about the same concept here.
80
81### KnownEnvironment
82
83A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach.
84
85It is not server-authored. It is local to a device or client profile.
86
87Examples:
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
94A known environment may or may not know the target `environmentId` before first successful connect.
95
96### AccessEndpoint
97
98An `AccessEndpoint` is one concrete way to reach a known environment.
99
100This is the key abstraction that keeps SSH from taking over the model.
101
102A 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
109The 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
115It 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
119The current `Project` model remains environment-local.
120
121That 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
129Access methods answer one question:
130
131How does the client speak WebSocket to a T3 server?
132
133They 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
141Examples:
142
143- `ws://10.0.0.15:3773`
144- `wss://t3.example.com`
145
146This is the base model and should be the first-class default.
147
148Benefits:
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
156Examples:
157
158- public relay URLs
159- private network relay URLs
160- local tunnel products such as pipenet
161
162This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay.
163
164For T3, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment.
165
166This 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
175SSH is an access and launch helper, not a separate environment type.
176
177The 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
184After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port.
185
186This keeps the renderer transport model consistent with every other access method.
187
188## Launch methods
189
190Launch methods answer a different question:
191
192How does a T3 server come to exist on the target machine?
193
194Launch and access should stay separate in the design.
195
196### 1. Pre-existing server
197
198The simplest launch method is no launch at all.
199
200The user or operator already runs T3 on the target machine, and the client connects through a direct or tunneled WebSocket endpoint.
201
202This 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
206This is the main place where Zed is a useful reference.
207
208Useful 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
216What 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
222The recommended T3 flow is:
223
2241. Desktop connects over SSH.
2252. Desktop probes the remote machine and verifies T3 availability.
2263. Desktop launches or reuses a remote T3 server.
2274. Desktop establishes local port forwarding.
2285. Renderer connects to the forwarded WebSocket endpoint as a normal environment.
229
230### 3. Client-managed local publish
231
232This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel.
233
234This is useful for:
235
236- exposing a desktop-hosted environment to mobile
237- temporary remote access without changing router or firewall settings
238
239This is still a launch concern, not a new environment kind.
240
241## Why access and launch must stay separate
242
243These concerns are easy to conflate, but separating them prevents architectural drift.
244
245Examples:
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
252In all of those cases, the `ExecutionEnvironment` is the same kind of thing.
253
254Only the launch and access paths differ.
255
256## Security model
257
258Remote support must assume that some environments will be reachable over untrusted networks.
259
260That 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
266T3 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
268For publicly reachable environments, authenticated access should be treated as required.
269
270## Relationship to Zed
271
272Zed is a useful reference implementation for managed remote launch and reconnect behavior.
273
274The 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
281The important mismatch is transport shape.
282
283Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime.
284
285T3 should not copy that part.
286
287T3 already has the right runtime boundary:
288
289- one T3 server per environment
290- ordinary HTTP/WebSocket between client and environment
291
292So T3 should borrow Zed's launch discipline, not its transport protocol.
293
294## Recommended rollout
295
2961. First-class known environments and access endpoints.
2972. Direct `ws` / `wss` remote environments.
2983. Authenticated tunnel-backed environments.
2994. Desktop-managed SSH launch and forwarding.
3005. Multi-environment UI improvements after the base runtime path is proven.
301
302This 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:
7272 - name: Verify preload bundle output
7373 run: |
7474 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
7676
7777 release_smoke:
7878 name: Release Smoke
.oxfmtrc.json+2-1
......@@ -9,7 +9,8 @@
99 "bun.lock",
1010 "*.tsbuildinfo",
1111 "**/routeTree.gen.ts",
12 "apps/web/public/mockServiceWorker.js"
12 "apps/web/public/mockServiceWorker.js",
13 "apps/web/src/lib/vendor/qrcodegen.ts"
1314 ],
1415 "sortPackageJson": {}
1516}
.plans/18-server-auth-model.md created+823
......@@ -0,0 +1,823 @@
1# Server Auth Model Plan
2
3## Purpose
4
5Define the long-term server auth architecture for T3 Code before first-class remote environments ship.
6
7This 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
9This 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
32Every 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
38The 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
42The system should distinguish:
43
44- bootstrap credentials
45- session credentials
46
47Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated.
48
49Session credentials are the durable credentials used after pairing.
50
51Bootstrap should never become the long-lived request credential.
52
53### 3. Auth and transport are separate
54
55Auth must not be defined by how the client reached the server.
56
57Examples:
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
65All of these should feed into the same auth model.
66
67### 4. Exposure level changes defaults
68
69The more exposed an environment is, the narrower the safe default should be.
70
71Safe 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
80The auth model should support more than one session credential type even if only one ships first.
81
82Examples:
83
84- browser session cookie
85- native bearer/device token
86
87This 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
93Every route or transport entrypoint should be classified as one of:
94
951. `public`
962. `bootstrap`
973. `authenticated`
98
99#### `public`
100
101Unauthenticated by definition.
102
103Should 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
111Used only to exchange a bootstrap credential for a session.
112
113Examples:
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
121Everything that reveals machine state or mutates it.
122
123Examples:
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
134The default stance should be: if it touches the machine, it is authenticated.
135
136## Credential model
137
138### Bootstrap credentials
139
140Initial credential types to model:
141
142- `desktop-bootstrap`
143- `one-time-token`
144
145Possible future credential types:
146
147- `device-code`
148- `passkey-assertion`
149- `external-identity`
150
151#### `desktop-bootstrap`
152
153Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments.
154
155Properties:
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
164Used for explicit browser/mobile pairing flows.
165
166Properties:
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
175Initial credential types to model:
176
177- `browser-session-cookie`
178- `bearer-session-token`
179
180#### `browser-session-cookie`
181
182Primary browser credential.
183
184Properties:
185
186- signed
187- `HttpOnly`
188- bounded lifetime
189- revocable by server key rotation or session invalidation
190
191#### `bearer-session-token`
192
193Reserved for native/mobile or non-browser clients.
194
195Properties:
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
203Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics.
204
205### Policy examples
206
207#### `DesktopManagedLocalPolicy`
208
209Default for desktop-managed local server.
210
211Allowed bootstrap methods:
212
213- `desktop-bootstrap`
214
215Allowed session methods:
216
217- `browser-session-cookie`
218
219Disabled by default:
220
221- `one-time-token`
222- `bearer-session-token`
223- password login
224- public pairing
225
226#### `LoopbackBrowserPolicy`
227
228Used for browser access on localhost without desktop-managed bootstrap.
229
230Allowed bootstrap methods:
231
232- `one-time-token`
233
234Allowed session methods:
235
236- `browser-session-cookie`
237
238#### `RemoteReachablePolicy`
239
240Used when binding non-loopback or using an explicit remote/tunnel workflow.
241
242Allowed bootstrap methods:
243
244- `one-time-token`
245- possibly `desktop-bootstrap` when a desktop shell is brokering access
246
247Allowed session methods:
248
249- `browser-session-cookie`
250- `bearer-session-token`
251
252#### `UnsafeNoAuthPolicy`
253
254Should exist only as an explicit escape hatch.
255
256Requirements:
257
258- explicit opt-in flag
259- loud startup warnings
260- never defaulted automatically
261
262## Effect-native service model
263
264### `ServerAuth`
265
266The main auth facade used by HTTP routes and WebSocket upgrade handling.
267
268Responsibilities:
269
270- classify requests
271- authenticate requests
272- authorize bootstrap attempts
273- create sessions from bootstrap credentials
274- enforce policy by environment mode
275
276Sketch:
277
278```ts
279export 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
293export class ServerAuth extends ServiceMap.Service<ServerAuth, ServerAuthShape>()(
294 "t3/ServerAuth",
295) {}
296```
297
298### `BootstrapCredentialService`
299
300Owns issuance, storage, validation, and consumption of bootstrap credentials.
301
302Responsibilities:
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
309Sketch:
310
311```ts
312export 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
327Owns creation and validation of authenticated sessions.
328
329Responsibilities:
330
331- mint cookie sessions
332- mint bearer sessions
333- validate active session credentials
334- revoke sessions if needed later
335
336Sketch:
337
338```ts
339export 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
357Pure policy/config service that decides which credential types are allowed.
358
359Responsibilities:
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
365This should stay mostly pure and cheap to test.
366
367### `ServerSecretStore`
368
369Owns long-lived server signing keys and secrets.
370
371Responsibilities:
372
373- get or create signing key
374- rotate signing key
375- abstract secure OS-backed storage vs filesystem fallback
376
377Important:
378
379- prefer platform secure storage when available
380- support hardened filesystem fallback for headless/server-only environments
381
382### `BrowserSessionCookieCodec`
383
384Focused utility service for cookie encode/decode/signing behavior.
385
386This should not own policy. It should only own the cookie format.
387
388### `AuthRouteGuards`
389
390Thin helper layer used by routes to enforce auth consistently.
391
392Responsibilities:
393
394- require auth for HTTP route handlers
395- classify route auth mode
396- convert auth failures into `401` / `403`
397
398This prevents every route from re-implementing the same pattern.
399
400Integrates with `HttpRouter.middleware` to enforce auth consistently.
401
402## Suggested layer graph
403
404```text
405ServerSecretStore
406 ├─> BootstrapCredentialService
407 ├─> BrowserSessionCookieCodec
408 └─> SessionCredentialService
409
410ServerAuthPolicy
411 ├─> BootstrapCredentialService
412 ├─> SessionCredentialService
413 └─> ServerAuth
414
415ServerAuth
416 └─> AuthRouteGuards
417```
418
419Layer 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
432Current state:
433
434- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts)
435
436Target shape:
437
438```ts
439const 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
449Then the `/ws` route becomes:
450
451```ts
452export const websocketRpcRouteLayer = HttpRouter.add(
453 "GET",
454 "/ws",
455 rpcWebSocketHttpEffect.pipe(
456 websocketUpgradeAuth,
457 Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)),
458 ),
459);
460```
461
462This keeps the route itself declarative and makes auth compose like normal HTTP middleware.
463
464### Example: authenticated HTTP route
465
466For routes like attachments or project favicon:
467
468```ts
469const 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
480Then:
481
482```ts
483export 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
495The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel.
496
497That grant is then exchanged for a browser cookie session when the renderer loads.
498
499Sketch:
500
501```ts
502const 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
512The 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
516For browser-driven pairing:
517
518```ts
519const 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
528The 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
532These flows are meant to anchor the auth model in concrete user journeys.
533
534The 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
542This is the default desktop-managed local flow.
543
544The 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
547Participants:
548 DesktopMain = Electron main
549 SecretStore = secure local secret backend
550 T3Server = local backend child process
551 Frontend = desktop renderer
552
553DesktopMain -> SecretStore : getOrCreate("server-signing-key")
554SecretStore --> DesktopMain : signing key available
555
556DesktopMain -> T3Server : spawn server (--bootstrap-fd ...)
557DesktopMain -> T3Server : send desktop bootstrap envelope
558note over T3Server : policy = DesktopManagedLocalPolicy
559note over T3Server : allowed pairing = desktop-bootstrap only
560
561Frontend -> DesktopMain : request local bootstrap grant
562DesktopMain --> Frontend : short-lived desktop bootstrap grant
563
564Frontend -> T3Server : POST /api/auth/bootstrap
565T3Server -> T3Server : validate desktop bootstrap grant
566T3Server -> T3Server : create browser session
567T3Server --> Frontend : Set-Cookie: session=...
568
569Frontend -> T3Server : GET /ws + authenticated cookie
570T3Server -> T3Server : validate cookie session
571T3Server --> Frontend : websocket accepted
572```
573
574### `npx t3` user
575
576This is the standalone local server flow.
577
578There is no trusted desktop shell here, so pairing should be explicit.
579
580```text
581Participants:
582 UserShell = npx t3 launcher
583 T3Server = standalone local server
584 Browser = browser tab
585
586UserShell -> T3Server : start server
587T3Server -> T3Server : getOrCreate("server-signing-key")
588note over T3Server : policy = LoopbackBrowserPolicy
589
590UserShell -> T3Server : issue one-time pairing token
591T3Server --> UserShell : pairing URL or pairing token
592
593UserShell --> Browser : open /pair?token=...
594
595Browser -> T3Server : GET /pair?token=...
596T3Server -> T3Server : validate one-time token
597T3Server -> T3Server : create browser session
598T3Server --> Browser : Set-Cookie: session=...
599T3Server --> Browser : redirect to app
600
601Browser -> T3Server : GET /ws + authenticated cookie
602T3Server --> Browser : websocket accepted
603```
604
605### Phone user with tunneled host
606
607This is the explicit remote access flow for a browser on another device.
608
609The tunnel only provides reachability. It must not imply trust.
610
611Recommended 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
618Participants:
619 DesktopUser = user at the host machine
620 DesktopMain = desktop app
621 Tunnel = tunnel provider
622 T3Server = T3 server
623 PhoneBrowser = mobile browser
624
625DesktopUser -> DesktopMain : enable remote access via tunnel
626DesktopMain -> T3Server : switch policy to RemoteReachablePolicy
627DesktopMain -> Tunnel : publish local T3 endpoint
628Tunnel --> DesktopMain : public https/wss URL
629
630DesktopMain -> T3Server : issue one-time pairing token
631T3Server --> DesktopMain : pairing token
632DesktopMain -> DesktopUser : show QR code / shareable URL
633
634DesktopUser -> PhoneBrowser : scan QR / open URL
635PhoneBrowser -> Tunnel : GET https://public-host/pair?token=...
636Tunnel -> T3Server : forward request
637T3Server -> T3Server : validate one-time token
638T3Server -> T3Server : create mobile browser session
639T3Server --> PhoneBrowser : Set-Cookie: session=...
640T3Server --> PhoneBrowser : redirect to app
641
642PhoneBrowser -> Tunnel : GET /ws + authenticated cookie
643Tunnel -> T3Server : forward websocket upgrade
644T3Server --> PhoneBrowser : websocket accepted
645```
646
647### Phone user with private network
648
649This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale.
650
651The auth flow should stay the same.
652
653```text
654Participants:
655 DesktopUser = user at the host machine
656 T3Server = T3 server
657 PrivateNet = tailscale / private LAN
658 PhoneBrowser = mobile browser
659
660DesktopUser -> T3Server : enable private-network access
661T3Server -> T3Server : switch policy to RemoteReachablePolicy
662DesktopUser -> T3Server : issue one-time pairing token
663T3Server --> DesktopUser : pairing URL / QR
664
665DesktopUser -> PhoneBrowser : open private-network URL
666PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=...
667PrivateNet -> T3Server : route request
668T3Server -> T3Server : validate one-time token
669T3Server -> T3Server : create mobile browser session
670T3Server --> PhoneBrowser : Set-Cookie: session=...
671T3Server --> PhoneBrowser : redirect to app
672
673PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie
674PrivateNet -> T3Server : websocket upgrade
675T3Server --> PhoneBrowser : websocket accepted
676```
677
678### Desktop user adding new SSH hosts
679
680SSH should be treated as launch and reachability plumbing, not as the long-term auth model.
681
682The 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
685Participants:
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
693DesktopUser -> DesktopMain : add SSH host
694DesktopMain -> SSH : connect to remote host
695SSH -> RemoteHost : probe environment / verify t3 availability
696DesktopMain -> SSH : run remote launch command
697SSH -> RemoteHost : t3 remote launch --json
698RemoteHost -> RemoteT3 : start or reuse server
699RemoteT3 --> RemoteHost : port + environment metadata
700RemoteHost --> SSH : launch result JSON
701SSH --> DesktopMain : remote server details
702
703DesktopMain -> SSH : establish local port forward
704SSH --> DesktopMain : localhost:FORWARDED_PORT ready
705
706note over RemoteT3 : policy = RemoteReachablePolicy
707note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here
708
709Frontend -> DesktopMain : request bootstrap for selected environment
710DesktopMain --> Frontend : short-lived bootstrap grant
711
712Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port
713RemoteT3 -> RemoteT3 : validate bootstrap grant
714RemoteT3 -> RemoteT3 : create browser session
715RemoteT3 --> Frontend : Set-Cookie: session=...
716
717Frontend -> RemoteT3 : GET /ws + authenticated cookie
718RemoteT3 --> Frontend : websocket accepted
719```
720
721## Storage decisions
722
723### Server secrets
724
725Use a `ServerSecretStore` abstraction.
726
727Preferred order (use a layer for each, resolve on startup):
728
7291. OS secure storage if available
7302. hardened filesystem fallback if not
731
732The 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
736Client-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
742This concern should stay in the client shell/runtime layer, not the server auth layer.
743
744## What to build now
745
746These are the parts worth building before remote environments ship:
747
7481. `ServerAuth` service boundary.
7492. route classification and route guards.
7503. `ServerSecretStore` abstraction.
7514. bootstrap vs session credential split.
7525. browser session cookie codec as one session method.
7536. explicit auth capabilities/config surfaced in contracts.
754
755Even 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
7591. Browser pairing flow using one-time bootstrap token and cookie session.
7602. Desktop-managed auto-bootstrap for the local desktop-managed environment.
7613. Auth-required defaults for any non-loopback or explicitly published server.
7624. 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
774These can all sit on top of the same bootstrap/session/service split.
775
776## Relationship to future remote environments
777
778Remote access is one reason this auth model matters, but the auth model should not be remote-shaped.
779
780Keep the design focused on:
781
782- one T3 server
783- one auth policy
784- multiple credential types
785- multiple future access methods
786
787That 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";
55import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
66import { waitForResources } from "./wait-for-resources.mjs";
77
8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);
9const devServerUrl = `http://localhost:${port}`;
8const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
9if (!devServerUrl) {
10 throw new Error("VITE_DEV_SERVER_URL is required for desktop development.");
11}
12
13const devServer = new URL(devServerUrl);
14const port = Number.parseInt(devServer.port, 10);
15if (!Number.isInteger(port) || port <= 0) {
16 throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`);
17}
18
1019const requiredFiles = [
1120 "dist-electron/main.js",
1221 "dist-electron/preload.js",
......@@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200;
2332await waitForResources({
2433 baseDir: desktopDir,
2534 files: requiredFiles,
35 tcpHost: devServer.hostname,
2636 tcpPort: port,
2737});
2838
......@@ -62,10 +72,7 @@ function startApp() {
6272 [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"],
6373 {
6474 cwd: desktopDir,
65 env: {
66 ...childEnv,
67 VITE_DEV_SERVER_URL: devServerUrl,
68 },
75 env: childEnv,
6976 stdio: "inherit",
7077 },
7178 );
apps/desktop/src/backendReadiness.test.ts created+83
......@@ -0,0 +1,83 @@
1import { describe, expect, it, vi } from "vitest";
2
3import {
4 BackendReadinessAbortedError,
5 isBackendReadinessAborted,
6 waitForHttpReady,
7} from "./backendReadiness";
8
9describe("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 @@
1export 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
9const DEFAULT_TIMEOUT_MS = 10_000;
10const DEFAULT_INTERVAL_MS = 100;
11const DEFAULT_REQUEST_TIMEOUT_MS = 1_000;
12
13export class BackendReadinessAbortedError extends Error {
14 constructor() {
15 super("Backend readiness wait was aborted.");
16 this.name = "BackendReadinessAbortedError";
17 }
18}
19
20function 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
47export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError {
48 return error instanceof BackendReadinessAbortedError;
49}
50
51export 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 @@
1import * as fs from "node:fs";
2import * as os from "node:os";
3import * as path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import {
8 DEFAULT_DESKTOP_SETTINGS,
9 readDesktopSettings,
10 setDesktopServerExposurePreference,
11 writeDesktopSettings,
12} from "./desktopSettings";
13
14const tempDirectories: string[] = [];
15
16afterEach(() => {
17 for (const directory of tempDirectories.splice(0)) {
18 fs.rmSync(directory, { recursive: true, force: true });
19 }
20});
21
22function 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
28describe("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 @@
1import * as FS from "node:fs";
2import * as Path from "node:path";
3import type { DesktopServerExposureMode } from "@t3tools/contracts";
4
5export interface DesktopSettings {
6 readonly serverExposureMode: DesktopServerExposureMode;
7}
8
9export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
10 serverExposureMode: "local-only",
11};
12
13export 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
25export 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
45export 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";
1919import * as Effect from "effect/Effect";
2020import type {
2121 DesktopTheme,
22 DesktopServerExposureMode,
23 DesktopServerExposureState,
2224 DesktopUpdateActionResult,
2325 DesktopUpdateCheckResult,
2426 DesktopUpdateState,
......@@ -29,7 +31,15 @@ import type { ContextMenuItem } from "@t3tools/contracts";
2931import { NetService } from "@t3tools/shared/Net";
3032import { RotatingFileSink } from "@t3tools/shared/logging";
3133import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
34import {
35 DEFAULT_DESKTOP_SETTINGS,
36 readDesktopSettings,
37 setDesktopServerExposurePreference,
38 writeDesktopSettings,
39} from "./desktopSettings";
40import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness";
3241import { showDesktopConfirmDialog } from "./confirmDialog";
42import { resolveDesktopServerExposure } from "./serverExposure";
3343import { syncShellEnvironment } from "./syncShellEnvironment";
3444import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
3545import {
......@@ -59,10 +69,12 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
5969const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
6070const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
6171const UPDATE_CHECK_CHANNEL = "desktop:update-check";
62const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
6372const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
73const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
74const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
6475const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
6576const STATE_DIR = Path.join(BASE_DIR, "userdata");
77const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");
6678const DESKTOP_SCHEME = "t3";
6779const ROOT_DIR = Path.resolve(__dirname, "../../..");
6880const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
......@@ -83,6 +95,7 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
8395const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
8496const DESKTOP_UPDATE_CHANNEL = "latest";
8597const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
98const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
8699
87100type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
88101type LinuxDesktopNamedApp = Electron.App & {
......@@ -92,8 +105,13 @@ type LinuxDesktopNamedApp = Electron.App & {
92105let mainWindow: BrowserWindow | null = null;
93106let backendProcess: ChildProcess.ChildProcess | null = null;
94107let backendPort = 0;
95let backendAuthToken = "";
108let backendBindHost = DESKTOP_LOOPBACK_HOST;
109let backendBootstrapToken = "";
110let backendHttpUrl = "";
96111let backendWsUrl = "";
112let backendEndpointUrl: string | null = null;
113let backendAdvertisedHost: string | null = null;
114let backendReadinessAbortController: AbortController | null = null;
97115let restartAttempt = 0;
98116let restartTimer: ReturnType<typeof setTimeout> | null = null;
99117let isQuitting = false;
......@@ -103,6 +121,8 @@ let desktopLogSink: RotatingFileSink | null = null;
103121let backendLogSink: RotatingFileSink | null = null;
104122let restoreStdIoCapture: (() => void) | null = null;
105123let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
124let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH);
125let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode;
106126
107127let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
108128const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
......@@ -141,17 +161,120 @@ function readPersistedBackendObservabilitySettings(): {
141161 }
142162}
143163
164function 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
177function 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
144186function backendChildEnv(): NodeJS.ProcessEnv {
145187 const env = { ...process.env };
146188 delete env.T3CODE_PORT;
147 delete env.T3CODE_AUTH_TOKEN;
148189 delete env.T3CODE_MODE;
149190 delete env.T3CODE_NO_BROWSER;
150191 delete env.T3CODE_HOST;
151192 delete env.T3CODE_DESKTOP_WS_URL;
193 delete env.T3CODE_DESKTOP_LAN_ACCESS;
194 delete env.T3CODE_DESKTOP_LAN_HOST;
152195 return env;
153196}
154197
198function getDesktopServerExposureState(): DesktopServerExposureState {
199 return {
200 mode: desktopServerExposureMode,
201 endpointUrl: backendEndpointUrl,
202 advertisedHost: backendAdvertisedHost,
203 };
204}
205
206function resolveAdvertisedHostOverride(): string | undefined {
207 const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim();
208 return override && override.length > 0 ? override : undefined;
209}
210
211async 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
251function 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
155278function writeDesktopLogHeader(message: string): void {
156279 if (!desktopLogSink) return;
157280 desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
......@@ -199,6 +322,27 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null {
199322 return null;
200323}
201324
325async 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
341function cancelBackendReadinessWait(): void {
342 backendReadinessAbortController?.abort();
343 backendReadinessAbortController = null;
344}
345
202346function writeDesktopStreamChunk(
203347 streamName: "stdout" | "stderr",
204348 chunk: unknown,
......@@ -543,10 +687,7 @@ function dispatchMenuAction(action: string): void {
543687 const send = () => {
544688 if (targetWindow.isDestroyed()) return;
545689 targetWindow.webContents.send(MENU_ACTION_CHANNEL, action);
546 if (!targetWindow.isVisible()) {
547 targetWindow.show();
548 }
549 targetWindow.focus();
690 revealWindow(targetWindow);
550691 };
551692
552693 if (targetWindow.webContents.isLoadingMainFrame()) {
......@@ -767,6 +908,26 @@ function clearUpdatePollTimer(): void {
767908 }
768909}
769910
911function 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
770931function emitUpdateState(): void {
771932 for (const window of BrowserWindow.getAllWindows()) {
772933 if (window.isDestroyed()) continue;
......@@ -1036,7 +1197,8 @@ function startBackend(): void {
10361197 noBrowser: true,
10371198 port: backendPort,
10381199 t3Home: BASE_DIR,
1039 authToken: backendAuthToken,
1200 host: backendBindHost,
1201 desktopBootstrapToken: backendBootstrapToken,
10401202 ...(backendObservabilitySettings.otlpTracesUrl
10411203 ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }
10421204 : {}),
......@@ -1095,6 +1257,7 @@ function startBackend(): void {
10951257}
10961258
10971259function stopBackend(): void {
1260 cancelBackendReadinessWait();
10981261 if (restartTimer) {
10991262 clearTimeout(restartTimer);
11001263 restartTimer = null;
......@@ -1116,6 +1279,7 @@ function stopBackend(): void {
11161279}
11171280
11181281async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
1282 cancelBackendReadinessWait();
11191283 if (restartTimer) {
11201284 clearTimeout(restartTimer);
11211285 restartTimer = null;
......@@ -1168,19 +1332,38 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
11681332}
11691333
11701334function registerIpcHandlers(): void {
1171 ipcMain.removeAllListeners(GET_WS_URL_CHANNEL);
1172 ipcMain.on(GET_WS_URL_CHANNEL, (event) => {
1173 event.returnValue = backendWsUrl;
1174 });
1175
11761335 ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
11771336 ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
11781337 event.returnValue = {
11791338 label: "Local environment",
1180 wsUrl: backendWsUrl || null,
1339 httpBaseUrl: backendHttpUrl || null,
1340 wsBaseUrl: backendWsUrl || null,
1341 bootstrapToken: backendBootstrapToken || undefined,
11811342 } as const;
11821343 });
11831344
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
11841367 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
11851368 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
11861369 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
......@@ -1346,14 +1529,19 @@ function getIconOption(): { icon: string } | Record<string, never> {
13461529 return iconPath ? { icon: iconPath } : {};
13471530}
13481531
1532function getInitialWindowBackgroundColor(): string {
1533 return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
1534}
1535
13491536function createWindow(): BrowserWindow {
13501537 const window = new BrowserWindow({
13511538 width: 1100,
13521539 height: 780,
13531540 minWidth: 840,
13541541 minHeight: 620,
1355 show: false,
1542 show: isDevelopment,
13561543 autoHideMenuBar: true,
1544 backgroundColor: getInitialWindowBackgroundColor(),
13571545 ...getIconOption(),
13581546 title: APP_DISPLAY_NAME,
13591547 titleBarStyle: "hiddenInset",
......@@ -1410,15 +1598,20 @@ function createWindow(): BrowserWindow {
14101598 window.setTitle(APP_DISPLAY_NAME);
14111599 emitUpdateState();
14121600 });
1413 window.once("ready-to-show", () => {
1414 window.show();
1415 });
1601 if (!isDevelopment) {
1602 window.once("ready-to-show", () => {
1603 revealWindow(window);
1604 });
1605 }
14161606
14171607 if (isDevelopment) {
1418 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
1608 void window.loadURL(resolveDesktopDevServerUrl());
14191609 window.webContents.openDevTools({ mode: "detach" });
1610 setImmediate(() => {
1611 revealWindow(window);
1612 });
14201613 } else {
1421 void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`);
1614 void window.loadURL(resolveDesktopWindowUrl());
14221615 }
14231616
14241617 window.on("closed", () => {
......@@ -1430,6 +1623,14 @@ function createWindow(): BrowserWindow {
14301623 return window;
14311624}
14321625
1626function resolveDesktopWindowUrl(): string {
1627 if (backendHttpUrl) {
1628 return backendHttpUrl;
1629 }
1630
1631 return `${DESKTOP_SCHEME}://app`;
1632}
1633
14331634// Override Electron's userData path before the `ready` event so that
14341635// Chromium session data uses a filesystem-friendly directory name.
14351636// Must be called synchronously at the top level — before `app.whenReady()`.
......@@ -1439,21 +1640,72 @@ configureAppIdentity();
14391640
14401641async function bootstrap(): Promise<void> {
14411642 writeDesktopLogHeader("bootstrap start");
1442 backendPort = await Effect.service(NetService).pipe(
1443 Effect.flatMap((net) => net.reserveLoopbackPort()),
1444 Effect.provide(NetService.layer),
1445 Effect.runPromise,
1643 const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT);
1644 if (isDevelopment && configuredBackendPort === undefined) {
1645 throw new Error("T3CODE_PORT is required in desktop development.");
1646 }
1647
1648 backendPort =
1649 configuredBackendPort ??
1650 (await Effect.service(NetService).pipe(
1651 Effect.flatMap((net) => net.reserveLoopbackPort(DESKTOP_LOOPBACK_HOST)),
1652 Effect.provide(NetService.layer),
1653 Effect.runPromise,
1654 ));
1655 writeDesktopLogHeader(
1656 configuredBackendPort === undefined
1657 ? `reserved backend port via NetService port=${backendPort}`
1658 : `using configured backend port port=${backendPort}`,
14461659 );
1447 writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);
1448 backendAuthToken = Crypto.randomBytes(24).toString("hex");
1449 const baseUrl = `ws://127.0.0.1:${backendPort}`;
1450 backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`;
1451 writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`);
1660 backendBootstrapToken = Crypto.randomBytes(24).toString("hex");
1661 if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) {
1662 writeDesktopLogHeader(
1663 `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`,
1664 );
1665 }
1666 const serverExposureState = await applyDesktopServerExposureMode(
1667 desktopSettings.serverExposureMode,
1668 {
1669 persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
1670 },
1671 );
1672 writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`);
1673 if (serverExposureState.endpointUrl) {
1674 writeDesktopLogHeader(
1675 `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`,
1676 );
1677 } else if (desktopSettings.serverExposureMode === "network-accessible") {
1678 writeDesktopLogHeader(
1679 "bootstrap fell back to local-only because no advertised network host was available",
1680 );
1681 }
14521682
14531683 registerIpcHandlers();
14541684 writeDesktopLogHeader("bootstrap ipc handlers registered");
14551685 startBackend();
14561686 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");
14571709 mainWindow = createWindow();
14581710 writeDesktopLogHeader("bootstrap main window created");
14591711}
......@@ -1463,6 +1715,7 @@ app.on("before-quit", () => {
14631715 updateInstallInFlight = false;
14641716 writeDesktopLogHeader("before-quit received");
14651717 clearUpdatePollTimer();
1718 cancelBackendReadinessWait();
14661719 stopBackend();
14671720 restoreStdIoCapture?.();
14681721});
......@@ -1476,13 +1729,19 @@ app
14761729 registerDesktopProtocol();
14771730 configureAutoUpdater();
14781731 void bootstrap().catch((error) => {
1732 if (isBackendReadinessAborted(error) && isQuitting) {
1733 return;
1734 }
14791735 handleFatalStartupError("bootstrap", error);
14801736 });
14811737
14821738 app.on("activate", () => {
1483 if (BrowserWindow.getAllWindows().length === 0) {
1484 mainWindow = createWindow();
1739 const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0];
1740 if (existingWindow) {
1741 revealWindow(existingWindow);
1742 return;
14851743 }
1744 mainWindow = createWindow();
14861745 });
14871746 })
14881747 .catch((error) => {
......@@ -1501,6 +1760,7 @@ if (process.platform !== "win32") {
15011760 isQuitting = true;
15021761 writeDesktopLogHeader("SIGINT received");
15031762 clearUpdatePollTimer();
1763 cancelBackendReadinessWait();
15041764 stopBackend();
15051765 restoreStdIoCapture?.();
15061766 app.quit();
apps/desktop/src/preload.ts+4-5
......@@ -12,14 +12,11 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
1212const UPDATE_CHECK_CHANNEL = "desktop:update-check";
1313const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1414const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
15const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
1615const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
16const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
17const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
1718
1819contextBridge.exposeInMainWorld("desktopBridge", {
19 getWsUrl: () => {
20 const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
21 return typeof result === "string" ? result : null;
22 },
2320 getLocalEnvironmentBootstrap: () => {
2421 const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
2522 if (typeof result !== "object" || result === null) {
......@@ -27,6 +24,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
2724 }
2825 return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;
2926 },
27 getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
28 setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
3029 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
3130 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
3231 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
apps/desktop/src/serverExposure.test.ts created+139
......@@ -0,0 +1,139 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure";
4
5describe("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
77describe("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 @@
1import type { NetworkInterfaceInfo } from "node:os";
2import type { DesktopServerExposureMode } from "@t3tools/contracts";
3
4const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
5const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
6
7export 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
16const normalizeOptionalHost = (value: string | undefined): string | undefined => {
17 const normalized = value?.trim();
18 return normalized && normalized.length > 0 ? normalized : undefined;
19};
20
21const isUsableLanIpv4Address = (address: string): boolean =>
22 !address.startsWith("127.") && !address.startsWith("169.254.");
23
24export 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
47export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import { ServerConfigShape } from "../../config.ts";
6import { ServerConfig } from "../../config.ts";
7import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
8import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
9import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
10import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
11import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
12import { makeAuthControlPlane } from "./AuthControlPlane.ts";
13import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
14
15const 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
31const 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
42it.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 @@
1import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts";
2import { DateTime, Effect, Layer } from "effect";
3
4import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
5import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
6import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
7import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
8import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
9import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Layers/Sqlite.ts";
10import {
11 AuthControlPlane,
12 AuthControlPlaneError,
13 AuthControlPlaneShape,
14 DEFAULT_SESSION_SUBJECT,
15 IssuedBearerSession,
16 IssuedPairingLink,
17} from "../Services/AuthControlPlane.ts";
18
19const 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
29const toAuthControlPlaneError =
30 (message: string) =>
31 (cause: unknown): AuthControlPlaneError =>
32 new AuthControlPlaneError({
33 message,
34 cause,
35 });
36
37export 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
163export const AuthCoreLive = Layer.mergeAll(
164 BootstrapCredentialServiceLive,
165 SessionCredentialServiceLive,
166);
167
168export const AuthStorageLive = Layer.mergeAll(ServerSecretStoreLive, SqlitePersistenceLayerLive);
169
170export const AuthRuntimeLive = AuthCoreLive.pipe(Layer.provideMerge(AuthStorageLive));
171
172export const AuthControlPlaneLive = Layer.effect(AuthControlPlane, makeAuthControlPlane);
173
174export const AuthControlPlaneRuntimeLive = AuthControlPlaneLive.pipe(
175 Layer.provideMerge(AuthRuntimeLive),
176);
apps/server/src/auth/Layers/BootstrapCredentialService.test.ts created+151
......@@ -0,0 +1,151 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Duration, Effect, Layer } from "effect";
4import { TestClock } from "effect/testing";
5
6import type { ServerConfigShape } from "../../config.ts";
7import { ServerConfig } from "../../config.ts";
8import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
9import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
10import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
11
12const 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
28const makeBootstrapCredentialLayer = (
29 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
30) =>
31 BootstrapCredentialServiceLive.pipe(
32 Layer.provide(SqlitePersistenceMemory),
33 Layer.provide(makeServerConfigLayer(overrides)),
34 );
35
36it.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 @@
1import type { AuthPairingLink } from "@t3tools/contracts";
2import { DateTime, Duration, Effect, Layer, PubSub, Ref, Stream } from "effect";
3import { Option } from "effect";
4
5import { ServerConfig } from "../../config.ts";
6import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts";
7import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts";
8import {
9 BootstrapCredentialError,
10 BootstrapCredentialService,
11 type BootstrapCredentialChange,
12 type BootstrapCredentialServiceShape,
13 type BootstrapGrant,
14 type IssuedBootstrapCredential,
15} from "../Services/BootstrapCredentialService.ts";
16
17interface StoredBootstrapGrant extends BootstrapGrant {
18 readonly remainingUses: number | "unbounded";
19}
20
21type 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
32const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
33const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
34const PAIRING_TOKEN_LENGTH = 12;
35
36const 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
42export 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
293export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import type { ServerConfigShape } from "../../config.ts";
6import { ServerConfig } from "../../config.ts";
7import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
8import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts";
9import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts";
10import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts";
11import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
12
13const 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
25const makeServerAuthLayer = (overrides?: Partial<ServerConfigShape>) =>
26 ServerAuthLive.pipe(
27 Layer.provide(SqlitePersistenceMemory),
28 Layer.provide(ServerSecretStoreLive),
29 Layer.provide(makeServerConfigLayer(overrides)),
30 );
31
32const 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
42const requestMetadata = {
43 deviceType: "desktop" as const,
44 os: "macOS",
45 browser: "Chrome",
46 ipAddress: "192.168.1.23",
47};
48
49it.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 @@
1import {
2 type AuthBearerBootstrapResult,
3 type AuthClientSession,
4 type AuthBootstrapResult,
5 type AuthPairingCredentialResult,
6 type AuthSessionState,
7 type AuthWebSocketTokenResult,
8} from "@t3tools/contracts";
9import { DateTime, Effect, Layer, Option } from "effect";
10import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
11
12import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
13import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
14import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
15import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts";
16import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts";
17import {
18 ServerAuth,
19 type AuthenticatedSession,
20 AuthError,
21 type ServerAuthShape,
22} from "../Services/ServerAuth.ts";
23import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
24import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts";
25
26type BootstrapExchangeResult = {
27 readonly response: AuthBootstrapResult;
28 readonly sessionToken: string;
29};
30
31const AUTHORIZATION_PREFIX = "Bearer ";
32const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken";
33
34export 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
50function 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
59export 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
381export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import type { ServerConfigShape } from "../../config.ts";
6import { ServerConfig } from "../../config.ts";
7import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts";
8import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
9
10const 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
28it.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 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
2import { Effect, Layer } from "effect";
3
4import { ServerConfig } from "../../config.ts";
5import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
6import { SESSION_COOKIE_NAME } from "../utils.ts";
7
8const isWildcardHost = (host: string | undefined): boolean =>
9 host === "0.0.0.0" || host === "::" || host === "[::]";
10
11const 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
25export 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
57export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy);
apps/server/src/auth/Layers/ServerSecretStore.test.ts created+263
......@@ -0,0 +1,263 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Cause, Deferred, Effect, FileSystem, Layer, Ref } from "effect";
4import * as PlatformError from "effect/PlatformError";
5
6import { ServerConfig } from "../../config.ts";
7import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts";
8import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
9
10const makeServerConfigLayer = () =>
11 ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" });
12
13const makeServerSecretStoreLayer = () =>
14 Layer.provide(ServerSecretStoreLive, makeServerConfigLayer());
15
16const 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
37const makePermissionDeniedSecretStoreLayer = () =>
38 ServerSecretStoreLive.pipe(
39 Layer.provide(makeServerConfigLayer()),
40 Layer.provideMerge(PermissionDeniedFileSystemLayer),
41 );
42
43const 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
64const makeRenameFailureSecretStoreLayer = () =>
65 ServerSecretStoreLive.pipe(
66 Layer.provide(makeServerConfigLayer()),
67 Layer.provideMerge(RenameFailureFileSystemLayer),
68 );
69
70const 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
91const makeRemoveFailureSecretStoreLayer = () =>
92 ServerSecretStoreLive.pipe(
93 Layer.provide(makeServerConfigLayer()),
94 Layer.provideMerge(RemoveFailureFileSystemLayer),
95 );
96
97const 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
137const makeConcurrentCreateSecretStoreLayer = () =>
138 ServerSecretStoreLive.pipe(
139 Layer.provide(makeServerConfigLayer()),
140 Layer.provideMerge(ConcurrentReadMissFileSystemLayer),
141 );
142
143it.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 @@
1import * as Crypto from "node:crypto";
2
3import { Effect, FileSystem, Layer, Path } from "effect";
4import * as PlatformError from "effect/PlatformError";
5
6import { ServerConfig } from "../../config.ts";
7import {
8 SecretStoreError,
9 ServerSecretStore,
10 type ServerSecretStoreShape,
11} from "../Services/ServerSecretStore.ts";
12
13export 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
151export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore);
apps/server/src/auth/Layers/SessionCredentialService.test.ts created+191
......@@ -0,0 +1,191 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Duration, Effect, Layer } from "effect";
4import { TestClock } from "effect/testing";
5
6import type { ServerConfigShape } from "../../config.ts";
7import { ServerConfig } from "../../config.ts";
8import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
9import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
10import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
11import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
12
13const 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
27const 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
36it.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 @@
1import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts";
2import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect";
3import { Option } from "effect";
4
5import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";
6import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
7import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
8import { SESSION_COOKIE_NAME } from "../utils.ts";
9import {
10 SessionCredentialError,
11 SessionCredentialService,
12 type IssuedSession,
13 type SessionCredentialChange,
14 type SessionCredentialServiceShape,
15 type VerifiedSession,
16} from "../Services/SessionCredentialService.ts";
17import {
18 base64UrlDecodeUtf8,
19 base64UrlEncode,
20 signPayload,
21 timingSafeEqualBase64Url,
22} from "../utils.ts";
23
24const SIGNING_SECRET_NAME = "server-signing-key";
25const DEFAULT_SESSION_TTL = Duration.days(30);
26const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
27
28const 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});
38type SessionClaims = typeof SessionClaims.Type;
39
40const 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});
47type WebSocketClaims = typeof WebSocketClaims.Type;
48
49function createDefaultClientMetadata(): AuthClientMetadata {
50 return {
51 deviceType: "unknown",
52 };
53}
54
55function 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
73function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession {
74 return {
75 ...input,
76 current: false,
77 };
78}
79
80export 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
490export 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 @@
1import type {
2 AuthClientMetadata,
3 AuthClientSession,
4 AuthPairingLink,
5 AuthSessionId,
6} from "@t3tools/contracts";
7import { Data, DateTime, Duration, Effect, ServiceMap } from "effect";
8import { SessionRole } from "./SessionCredentialService";
9
10export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
11
12export 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
22export 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
32export class AuthControlPlaneError extends Data.TaggedError("AuthControlPlaneError")<{
33 readonly message: string;
34 readonly cause?: unknown;
35}> {}
36
37export 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
67export class AuthControlPlane extends ServiceMap.Service<AuthControlPlane, AuthControlPlaneShape>()(
68 "t3/AuthControlPlane",
69) {}
apps/server/src/auth/Services/BootstrapCredentialService.ts created+57
......@@ -0,0 +1,57 @@
1import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts";
2import { Data, DateTime, Duration, ServiceMap } from "effect";
3import type { Effect, Stream } from "effect";
4
5export type BootstrapCredentialRole = "owner" | "client";
6
7export 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
15export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{
16 readonly message: string;
17 readonly status?: 401 | 500;
18 readonly cause?: unknown;
19}> {}
20
21export interface IssuedBootstrapCredential {
22 readonly id: string;
23 readonly credential: string;
24 readonly label?: string;
25 readonly expiresAt: DateTime.Utc;
26}
27
28export type BootstrapCredentialChange =
29 | {
30 readonly type: "pairingLinkUpserted";
31 readonly pairingLink: AuthPairingLink;
32 }
33 | {
34 readonly type: "pairingLinkRemoved";
35 readonly id: string;
36 };
37
38export 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
54export 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 @@
1import 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";
15import { Data, DateTime, ServiceMap } from "effect";
16import type { Effect } from "effect";
17import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
18import type { SessionRole } from "./SessionCredentialService.ts";
19
20export 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
28export class AuthError extends Data.TaggedError("AuthError")<{
29 readonly message: string;
30 readonly status?: 400 | 401 | 403 | 500;
31 readonly cause?: unknown;
32}> {}
33
34export 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
82export 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 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
2import { ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5export interface ServerAuthPolicyShape {
6 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
7}
8
9export 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 @@
1import { Data, ServiceMap } from "effect";
2import type { Effect } from "effect";
3
4export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{
5 readonly message: string;
6 readonly cause?: unknown;
7}> {}
8
9export 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
19export 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 @@
1import type {
2 AuthClientMetadata,
3 AuthClientSession,
4 AuthSessionId,
5 ServerAuthSessionMethod,
6} from "@t3tools/contracts";
7import { Data, DateTime, Duration, ServiceMap } from "effect";
8import type { Effect, Stream } from "effect";
9
10export type SessionRole = "owner" | "client";
11
12export 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
21export 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
31export type SessionCredentialChange =
32 | {
33 readonly type: "clientUpserted";
34 readonly clientSession: AuthClientSession;
35 }
36 | {
37 readonly type: "clientRemoved";
38 readonly sessionId: AuthSessionId;
39 };
40
41export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{
42 readonly message: string;
43 readonly cause?: unknown;
44}> {}
45
46export 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
84export 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 @@
1import {
2 type AuthBearerBootstrapResult,
3 AuthBootstrapInput,
4 AuthCreatePairingCredentialInput,
5 AuthRevokeClientSessionInput,
6 AuthRevokePairingLinkInput,
7 type AuthWebSocketTokenResult,
8} from "@t3tools/contracts";
9import { DateTime, Effect, Schema } from "effect";
10import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
11
12import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";
13import { SessionCredentialService } from "./Services/SessionCredentialService.ts";
14import { deriveAuthClientMetadata } from "./utils.ts";
15
16export 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
32export 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
43const 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
49function 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
60export 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
93export 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
119export 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
133export 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
173const 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
186export 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
196export 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
216export 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
226export 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
246export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { deriveAuthClientMetadata } from "./utils";
4
5describe("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 @@
1import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts";
2import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3import * as Crypto from "node:crypto";
4
5export const SESSION_COOKIE_NAME = "t3_session";
6
7export 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
12export function base64UrlDecodeUtf8(input: string): string {
13 return Buffer.from(input, "base64url").toString("utf8");
14}
15
16export function signPayload(payload: string, secret: Uint8Array): string {
17 return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url");
18}
19
20export 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
29function 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
37function 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
45function 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
63function 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
77function 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
90function 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
105export 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) => {
4343 cwd: Option.none(),
4444 devUrl: Option.none(),
4545 noBrowser: Option.none(),
46 authToken: Option.none(),
4746 bootstrapFd: Option.none(),
4847 autoBootstrapProjectFromCwd: Option.none(),
4948 logWebSocketEvents: Option.none(),
......@@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
6261 T3CODE_HOME: baseDir,
6362 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
6463 T3CODE_NO_BROWSER: "true",
65 T3CODE_AUTH_TOKEN: "env-token",
6664 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
6765 T3CODE_LOG_WS_EVENTS: "true",
6866 },
......@@ -85,7 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
8583 staticDir: undefined,
8684 devUrl: new URL("http://127.0.0.1:5173"),
8785 noBrowser: true,
88 authToken: "env-token",
86 desktopBootstrapToken: undefined,
8987 autoBootstrapProjectFromCwd: false,
9088 logWebSocketEvents: true,
9189 });
......@@ -106,7 +104,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
106104 cwd: Option.none(),
107105 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
108106 noBrowser: Option.some(true),
109 authToken: Option.some("flag-token"),
110107 bootstrapFd: Option.none(),
111108 autoBootstrapProjectFromCwd: Option.some(true),
112109 logWebSocketEvents: Option.some(true),
......@@ -125,7 +122,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
125122 T3CODE_HOME: join(os.tmpdir(), "ignored-base"),
126123 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
127124 T3CODE_NO_BROWSER: "false",
128 T3CODE_AUTH_TOKEN: "ignored-token",
129125 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
130126 T3CODE_LOG_WS_EVENTS: "false",
131127 },
......@@ -148,7 +144,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
148144 staticDir: undefined,
149145 devUrl: new URL("http://127.0.0.1:4173"),
150146 noBrowser: true,
151 authToken: "flag-token",
147 desktopBootstrapToken: undefined,
152148 autoBootstrapProjectFromCwd: true,
153149 logWebSocketEvents: true,
154150 });
......@@ -166,7 +162,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
166162 t3Home: baseDir,
167163 devUrl: "http://127.0.0.1:5173",
168164 noBrowser: true,
169 authToken: "bootstrap-token",
170165 autoBootstrapProjectFromCwd: false,
171166 logWebSocketEvents: true,
172167 otlpTracesUrl: "http://localhost:4318/v1/traces",
......@@ -183,7 +178,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
183178 cwd: Option.none(),
184179 devUrl: Option.none(),
185180 noBrowser: Option.none(),
186 authToken: Option.none(),
187181 bootstrapFd: Option.none(),
188182 autoBootstrapProjectFromCwd: Option.none(),
189183 logWebSocketEvents: Option.none(),
......@@ -218,7 +212,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
218212 staticDir: undefined,
219213 devUrl: new URL("http://127.0.0.1:5173"),
220214 noBrowser: true,
221 authToken: "bootstrap-token",
215 desktopBootstrapToken: undefined,
222216 autoBootstrapProjectFromCwd: false,
223217 logWebSocketEvents: true,
224218 });
......@@ -242,7 +236,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
242236 cwd: Option.some(customCwd),
243237 devUrl: Option.some(new URL("http://127.0.0.1:5173")),
244238 noBrowser: Option.none(),
245 authToken: Option.none(),
246239 bootstrapFd: Option.none(),
247240 autoBootstrapProjectFromCwd: Option.none(),
248241 logWebSocketEvents: Option.none(),
......@@ -285,7 +278,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
285278 t3Home: "/tmp/t3-bootstrap-home",
286279 devUrl: "http://127.0.0.1:5173",
287280 noBrowser: false,
288 authToken: "bootstrap-token",
289281 autoBootstrapProjectFromCwd: false,
290282 logWebSocketEvents: false,
291283 });
......@@ -300,7 +292,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
300292 cwd: Option.none(),
301293 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
302294 noBrowser: Option.none(),
303 authToken: Option.some("flag-token"),
304295 bootstrapFd: Option.none(),
305296 autoBootstrapProjectFromCwd: Option.none(),
306297 logWebSocketEvents: Option.none(),
......@@ -338,7 +329,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
338329 staticDir: undefined,
339330 devUrl: new URL("http://127.0.0.1:4173"),
340331 noBrowser: true,
341 authToken: "flag-token",
332 desktopBootstrapToken: undefined,
342333 autoBootstrapProjectFromCwd: true,
343334 logWebSocketEvents: true,
344335 });
......@@ -371,7 +362,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
371362 cwd: Option.none(),
372363 devUrl: Option.none(),
373364 noBrowser: Option.none(),
374 authToken: Option.none(),
375365 bootstrapFd: Option.none(),
376366 autoBootstrapProjectFromCwd: Option.none(),
377367 logWebSocketEvents: Option.none(),
......@@ -402,7 +392,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
402392 staticDir: resolved.staticDir,
403393 devUrl: undefined,
404394 noBrowser: true,
405 authToken: undefined,
395 desktopBootstrapToken: undefined,
406396 autoBootstrapProjectFromCwd: false,
407397 logWebSocketEvents: false,
408398 });
apps/server/src/cli.test.ts+103-7
......@@ -1,28 +1,41 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
15import * as NodeServices from "@effect/platform-node/NodeServices";
26import { NetService } from "@t3tools/shared/Net";
37import { assert, it } from "@effect/vitest";
48import * as Effect from "effect/Effect";
59import * as Layer from "effect/Layer";
610import * as CliError from "effect/unstable/cli/CliError";
11import * as TestConsole from "effect/testing/TestConsole";
712import { Command } from "effect/unstable/cli";
813
914import { cli } from "./cli.ts";
1015
1116const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1217
18const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
19const runCliWithRuntime = (args: ReadonlyArray<string>) =>
20 runCli(args).pipe(Effect.provide(CliRuntimeLayer));
21
22const 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
1331it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
1432 it.effect("accepts the built-in lowercase log-level flag values", () =>
15 Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe(
16 Effect.provide(CliRuntimeLayer),
17 ),
33 runCliWithRuntime(["--log-level", "debug", "--version"]),
1834 );
1935
2036 it.effect("rejects invalid log-level casing before launching the server", () =>
2137 Effect.gen(function* () {
22 const error = yield* Command.runWith(cli, { version: "0.0.0" })([
23 "--log-level",
24 "Debug",
25 ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip);
38 const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip);
2639
2740 if (!CliError.isCliError(error)) {
2841 assert.fail(`Expected CliError, got ${String(error)}`);
......@@ -34,4 +47,87 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
3447 assert.equal(error.value, "Debug");
3548 }),
3649 );
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 );
37133});
apps/server/src/cli.ts+353-23
......@@ -1,6 +1,20 @@
11import { NetService } from "@t3tools/shared/Net";
22import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect";
3import { AuthSessionId } from "@t3tools/contracts";
4import {
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";
418import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
519
620import {
......@@ -15,6 +29,14 @@ import {
1529import { readBootstrapEnvelope } from "./bootstrap";
1630import { expandHomePath, resolveBaseDir } from "./os-jank";
1731import { runServer } from "./server";
32import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts";
33import {
34 formatIssuedPairingCredential,
35 formatIssuedSession,
36 formatPairingCredentialList,
37 formatSessionList,
38} from "./cliAuthFormat";
39import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts";
1840
1941const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
2042
......@@ -25,7 +47,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({
2547 t3Home: Schema.optional(Schema.String),
2648 devUrl: Schema.optional(Schema.URLFromString),
2749 noBrowser: Schema.optional(Schema.Boolean),
28 authToken: Schema.optional(Schema.String),
50 desktopBootstrapToken: Schema.optional(Schema.String),
2951 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
3052 logWebSocketEvents: Schema.optional(Schema.Boolean),
3153 otlpTracesUrl: Schema.optional(Schema.String),
......@@ -58,11 +80,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe(
5880 Flag.withDescription("Disable automatic browser opening."),
5981 Flag.optional,
6082);
61const authTokenFlag = Flag.string("auth-token").pipe(
62 Flag.withDescription("Auth token required for WebSocket connections."),
63 Flag.withAlias("token"),
64 Flag.optional,
65);
6683const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
6784 Flag.withSchema(Schema.Int),
6885 Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
......@@ -117,10 +134,6 @@ const EnvServerConfig = Config.all({
117134 Config.option,
118135 Config.map(Option.getOrUndefined),
119136 ),
120 authToken: Config.string("T3CODE_AUTH_TOKEN").pipe(
121 Config.option,
122 Config.map(Option.getOrUndefined),
123 ),
124137 bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
125138 Config.option,
126139 Config.map(Option.getOrUndefined),
......@@ -143,12 +156,16 @@ interface CliServerFlags {
143156 readonly cwd: Option.Option<string>;
144157 readonly devUrl: Option.Option<URL>;
145158 readonly noBrowser: Option.Option<boolean>;
146 readonly authToken: Option.Option<string>;
147159 readonly bootstrapFd: Option.Option<number>;
148160 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
149161 readonly logWebSocketEvents: Option.Option<boolean>;
150162}
151163
164interface CliAuthLocationFlags {
165 readonly baseDir: Option.Option<string>;
166 readonly devUrl: Option.Option<URL>;
167}
168
152169const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
153170 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
154171
......@@ -248,13 +265,9 @@ export const resolveServerConfig = (
248265 () => mode === "desktop",
249266 ),
250267 );
251 const authToken = Option.getOrUndefined(
252 resolveOptionPrecedence(
253 flags.authToken,
254 Option.fromUndefinedOr(env.authToken),
255 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
256 Option.fromUndefinedOr(bootstrap.authToken),
257 ),
268 const desktopBootstrapToken = Option.getOrUndefined(
269 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
270 Option.fromUndefinedOr(bootstrap.desktopBootstrapToken),
258271 ),
259272 );
260273 const autoBootstrapProjectFromCwd = resolveBooleanFlag(
......@@ -327,7 +340,7 @@ export const resolveServerConfig = (
327340 staticDir,
328341 devUrl,
329342 noBrowser,
330 authToken,
343 desktopBootstrapToken,
331344 autoBootstrapProjectFromCwd,
332345 logWebSocketEvents,
333346 };
......@@ -335,6 +348,110 @@ export const resolveServerConfig = (
335348 return config;
336349 });
337350
351const 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
371const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i;
372
373const 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
414const 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
434const 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
338455const commandFlags = {
339456 mode: modeFlag,
340457 port: portFlag,
......@@ -348,13 +465,216 @@ const commandFlags = {
348465 ),
349466 devUrl: devUrlFlag,
350467 noBrowser: noBrowserFlag,
351 authToken: authTokenFlag,
352468 bootstrapFd: bootstrapFdFlag,
353469 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
354470 logWebSocketEvents: logWebSocketEventsFlag,
355471} as const;
356472
357const rootCommand = Command.make("t3", commandFlags).pipe(
473const authLocationFlags = {
474 baseDir: baseDirFlag,
475 devUrl: devUrlFlag,
476} as const;
477
478const 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
484const jsonFlag = Flag.boolean("json").pipe(
485 Flag.withDescription("Emit JSON instead of human-readable output."),
486 Flag.withDefault(false),
487);
488
489const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe(
490 Flag.withDescription("Role for the issued bearer session."),
491 Flag.withDefault("owner"),
492);
493
494const labelFlag = Flag.string("label").pipe(
495 Flag.withDescription("Optional human-readable label."),
496 Flag.optional,
497);
498
499const subjectFlag = Flag.string("subject").pipe(
500 Flag.withDescription("Optional session subject."),
501 Flag.optional,
502);
503
504const 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
509const tokenOnlyFlag = Flag.boolean("token-only").pipe(
510 Flag.withDescription("Print only the issued bearer token."),
511 Flag.withDefault(false),
512);
513
514const 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
546const 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
566const 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
585const pairingCommand = Command.make("pairing").pipe(
586 Command.withDescription("Manage one-time client pairing tokens."),
587 Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]),
588);
589
590const 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
625const 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
645const 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
667const sessionCommand = Command.make("session").pipe(
668 Command.withDescription("Manage bearer sessions."),
669 Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]),
670);
671
672const authCommand = Command.make("auth").pipe(
673 Command.withDescription("Manage the local auth control plane for headless deployments."),
674 Command.withSubcommands([pairingCommand, sessionCommand]),
675);
676
677const startCommand = Command.make("start", commandFlags).pipe(
358678 Command.withDescription("Run the T3 Code server."),
359679 Command.withHandler((flags) =>
360680 Effect.gen(function* () {
......@@ -365,4 +685,14 @@ const rootCommand = Command.make("t3", commandFlags).pipe(
365685 ),
366686);
367687
368export const cli = rootCommand;
688export 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 @@
1import { expect, it } from "@effect/vitest";
2import { DateTime } from "effect";
3
4import {
5 formatIssuedPairingCredential,
6 formatIssuedSession,
7 formatPairingCredentialList,
8 formatSessionList,
9} from "./cliAuthFormat.ts";
10
11it("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
28it("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
48it("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 @@
1import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts";
2import { DateTime } from "effect";
3
4import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts";
5
6const newline = "\n";
7
8function serializeOptionalFields(values: ReadonlyArray<string | null | undefined>) {
9 return values.filter((value): value is string => typeof value === "string" && value.length > 0);
10}
11
12function 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
23function toIsoString(value: DateTime.DateTime | DateTime.Utc): string {
24 return DateTime.formatIso(DateTime.toUtc(value));
25}
26
27export 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
69export 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
107export 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
145export 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 {
3131 readonly terminalLogsDir: string;
3232 readonly anonymousIdPath: string;
3333 readonly environmentIdPath: string;
34 readonly secretsDir: string;
3435}
3536
3637/**
......@@ -55,7 +56,7 @@ export interface ServerConfigShape extends ServerDerivedPaths {
5556 readonly staticDir: string | undefined;
5657 readonly devUrl: URL | undefined;
5758 readonly noBrowser: boolean;
58 readonly authToken: string | undefined;
59 readonly desktopBootstrapToken: string | undefined;
5960 readonly autoBootstrapProjectFromCwd: boolean;
6061 readonly logWebSocketEvents: boolean;
6162}
......@@ -85,6 +86,7 @@ export const deriveServerPaths = Effect.fn(function* (
8586 terminalLogsDir: join(logsDir, "terminals"),
8687 anonymousIdPath: join(stateDir, "anonymous-id"),
8788 environmentIdPath: join(stateDir, "environment-id"),
89 secretsDir: join(stateDir, "secrets"),
8890 };
8991});
9092
......@@ -147,7 +149,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
147149 logWebSocketEvents: false,
148150 port: 0,
149151 host: undefined,
150 authToken: undefined,
152 desktopBootstrapToken: undefined,
151153 staticDir: undefined,
152154 devUrl,
153155 noBrowser: false,
apps/server/src/environment/Layers/ServerEnvironment.test.ts+1-1
......@@ -32,7 +32,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
3232 logWebSocketEvents: false,
3333 port: 0,
3434 host: undefined,
35 authToken: undefined,
35 desktopBootstrapToken: undefined,
3636 staticDir: undefined,
3737 devUrl: undefined,
3838 noBrowser: false,
apps/server/src/environment/Layers/ServerEnvironment.ts+4-6
......@@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path, Random } from "effect";
44import { ServerConfig } from "../../config.ts";
55import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
66import { version } from "../../../package.json" with { type: "json" };
7import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
78
89function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] {
910 switch (process.platform) {
......@@ -65,12 +66,9 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function
6566
6667 const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw);
6768 const cwdBaseName = path.basename(serverConfig.cwd).trim();
68 const label =
69 serverConfig.mode === "desktop"
70 ? "Local environment"
71 : cwdBaseName.length > 0
72 ? cwdBaseName
73 : "T3 environment";
69 const label = yield* resolveServerEnvironmentLabel({
70 cwdBaseName,
71 });
7472
7573 const descriptor: ExecutionEnvironmentDescriptor = {
7674 environmentId,
apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts created+150
......@@ -0,0 +1,150 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { Effect, FileSystem } from "effect";
3import { vi } from "vitest";
4
5vi.mock("../../processRunner.ts", () => ({
6 runProcess: vi.fn(),
7}));
8
9import { runProcess } from "../../processRunner.ts";
10import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
11
12const mockedRunProcess = vi.mocked(runProcess);
13const NoopFileSystemLayer = FileSystem.layerNoop({});
14
15afterEach(() => {
16 mockedRunProcess.mockReset();
17});
18
19describe("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 @@
1import * as OS from "node:os";
2
3import { Effect, FileSystem } from "effect";
4
5import { runProcess } from "../../processRunner.ts";
6
7interface ResolveServerEnvironmentLabelInput {
8 readonly cwdBaseName: string;
9 readonly platform?: NodeJS.Platform;
10 readonly hostname?: string | null;
11}
12
13function normalizeLabel(value: string | null | undefined): string | null {
14 const trimmed = value?.trim();
15 return trimmed && trimmed.length > 0 ? trimmed : null;
16}
17
18function 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
36const 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
50const 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
67const 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
89export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
4
5describe("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 @@
11import Mime from "@effect/platform-node/Mime";
2import { Data, Effect, FileSystem, Layer, Option, Path } from "effect";
2import { Data, Effect, FileSystem, Option, Path } from "effect";
33import { cast } from "effect/Function";
44import {
55 HttpBody,
......@@ -17,14 +17,57 @@ import {
1717 resolveAttachmentRelativePath,
1818} from "./attachmentPaths";
1919import { resolveAttachmentPathById } from "./attachmentStore";
20import { ServerConfig } from "./config";
20import { resolveStaticDir, ServerConfig } from "./config";
2121import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts";
2222import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
2323import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver";
24import { ServerAuth } from "./auth/Services/ServerAuth.ts";
25import { respondToAuthError } from "./auth/http.ts";
26import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
2427
2528const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
2629const 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>`;
2730const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
31const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
32
33export const browserApiCorsLayer = HttpRouter.cors({
34 allowedMethods: ["GET", "POST", "OPTIONS"],
35 allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],
36 maxAge: 600,
37});
38
39export function isLoopbackHostname(hostname: string): boolean {
40 const normalizedHostname = hostname
41 .trim()
42 .toLowerCase()
43 .replace(/^\[(.*)\]$/, "$1");
44 return LOOPBACK_HOSTNAMES.has(normalizedHostname);
45}
46
47export 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
55const requireAuthenticatedRequest = Effect.gen(function* () {
56 const request = yield* HttpServerRequest.HttpServerRequest;
57 const serverAuth = yield* ServerAuth;
58 yield* serverAuth.authenticateHttpRequest(request);
59});
60
61export 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);
2871
2972class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{
3073 readonly cause: unknown;
......@@ -35,6 +78,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
3578 "POST",
3679 OTLP_TRACES_PROXY_PATH,
3780 Effect.gen(function* () {
81 yield* requireAuthenticatedRequest;
3882 const request = yield* HttpServerRequest.HttpServerRequest;
3983 const config = yield* ServerConfig;
4084 const otlpTracesUrl = config.otlpTracesUrl;
......@@ -76,21 +120,14 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
76120 Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })),
77121 ),
78122 );
79 }),
80).pipe(
81 Layer.provide(
82 HttpRouter.cors({
83 allowedMethods: ["POST", "OPTIONS"],
84 allowedHeaders: ["content-type"],
85 maxAge: 600,
86 }),
87 ),
123 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
88124);
89125
90126export const attachmentsRouteLayer = HttpRouter.add(
91127 "GET",
92128 `${ATTACHMENTS_ROUTE_PREFIX}/*`,
93129 Effect.gen(function* () {
130 yield* requireAuthenticatedRequest;
94131 const request = yield* HttpServerRequest.HttpServerRequest;
95132 const url = HttpServerRequest.toURL(request);
96133 if (Option.isNone(url)) {
......@@ -139,13 +176,14 @@ export const attachmentsRouteLayer = HttpRouter.add(
139176 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
140177 ),
141178 );
142 }),
179 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
143180);
144181
145182export const projectFaviconRouteLayer = HttpRouter.add(
146183 "GET",
147184 "/api/project-favicon",
148185 Effect.gen(function* () {
186 yield* requireAuthenticatedRequest;
149187 const request = yield* HttpServerRequest.HttpServerRequest;
150188 const url = HttpServerRequest.toURL(request);
151189 if (Option.isNone(url)) {
......@@ -179,7 +217,7 @@ export const projectFaviconRouteLayer = HttpRouter.add(
179217 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
180218 ),
181219 );
182 }),
220 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
183221);
184222
185223export const staticAndDevRouteLayer = HttpRouter.add(
......@@ -193,11 +231,14 @@ export const staticAndDevRouteLayer = HttpRouter.add(
193231 }
194232
195233 const config = yield* ServerConfig;
196 if (config.devUrl) {
197 return HttpServerResponse.redirect(config.devUrl.href, { status: 302 });
234 if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
235 return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
236 status: 302,
237 });
198238 }
199239
200 if (!config.staticDir) {
240 const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined);
241 if (!staticDir) {
201242 return HttpServerResponse.text("No static directory configured and no dev URL set.", {
202243 status: 503,
203244 });
......@@ -205,7 +246,7 @@ export const staticAndDevRouteLayer = HttpRouter.add(
205246
206247 const fileSystem = yield* FileSystem.FileSystem;
207248 const path = yield* Path.Path;
208 const staticRoot = path.resolve(config.staticDir);
249 const staticRoot = path.resolve(staticDir);
209250 const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
210251 const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
211252 const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
apps/server/src/keybindings.test.ts+18-1
......@@ -2,7 +2,7 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import { assert, it } from "@effect/vitest";
44import { assertFailure } from "@effect/vitest/utils";
5import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
5import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
66import { ServerConfig } from "./config";
77
88import {
......@@ -149,6 +149,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
149149 }),
150150 );
151151
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
152169 it.effect("bootstraps default keybindings when config file is missing", () =>
153170 Effect.gen(function* () {
154171 const fs = yield* FileSystem.FileSystem;
apps/server/src/keybindings.ts+2-2
......@@ -322,7 +322,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(
322322 Predicate.isNotNull,
323323 () =>
324324 new SchemaIssue.InvalidValue(Option.some(rule), {
325 title: "Invalid keybinding rule",
325 message: "Invalid keybinding rule",
326326 }),
327327 ),
328328 Effect.map((resolved) => resolved),
......@@ -334,7 +334,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(
334334 if (!key) {
335335 return yield* Effect.fail(
336336 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",
338338 }),
339339 );
340340 }
apps/server/src/persistence/Errors.ts+2
......@@ -101,5 +101,7 @@ export type OrchestrationCommandReceiptRepositoryError =
101101 | PersistenceDecodeError;
102102
103103export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError;
104export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError;
105export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError;
104106
105107export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError;
apps/server/src/persistence/Layers/AuthPairingLinks.ts created+209
......@@ -0,0 +1,209 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Schema } from "effect";
4
5import {
6 toPersistenceDecodeError,
7 toPersistenceSqlError,
8 type AuthPairingLinkRepositoryError,
9} from "../Errors.ts";
10import {
11 AuthPairingLinkRecord,
12 AuthPairingLinkRepository,
13 type AuthPairingLinkRepositoryShape,
14 ConsumeAuthPairingLinkInput,
15 CreateAuthPairingLinkInput,
16 GetAuthPairingLinkByCredentialInput,
17 ListActiveAuthPairingLinksInput,
18 RevokeAuthPairingLinkInput,
19} from "../Services/AuthPairingLinks.ts";
20
21function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
22 return (cause: unknown): AuthPairingLinkRepositoryError =>
23 Schema.isSchemaError(cause)
24 ? toPersistenceDecodeError(decodeOperation)(cause)
25 : toPersistenceSqlError(sqlOperation)(cause);
26}
27
28const 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
206export const AuthPairingLinkRepositoryLive = Layer.effect(
207 AuthPairingLinkRepository,
208 makeAuthPairingLinkRepository,
209);
apps/server/src/persistence/Layers/AuthSessions.ts created+279
......@@ -0,0 +1,279 @@
1import { AuthSessionId } from "@t3tools/contracts";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3import * as SqlSchema from "effect/unstable/sql/SqlSchema";
4import { Effect, Layer, Option, Schema } from "effect";
5
6import {
7 toPersistenceDecodeError,
8 toPersistenceSqlError,
9 type AuthSessionRepositoryError,
10} from "../Errors.ts";
11import {
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
23const 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
40function 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
61function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
62 return (cause: unknown): AuthSessionRepositoryError =>
63 Schema.isSchemaError(cause)
64 ? toPersistenceDecodeError(decodeOperation)(cause)
65 : toPersistenceSqlError(sqlOperation)(cause);
66}
67
68const 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
276export 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";
3232import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts";
3333import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts";
3434import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts";
35import Migration0020 from "./Migrations/020_AuthAccessManagement.ts";
36import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts";
37import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts";
3538
3639/**
3740 * Migration loader with all migrations defined inline.
......@@ -63,6 +66,9 @@ export const migrationEntries = [
6366 [17, "ProjectionThreadsArchivedAt", Migration0017],
6467 [18, "ProjectionThreadsArchivedAtIndex", Migration0018],
6568 [19, "ProjectionSnapshotLookupIndexes", Migration0019],
69 [20, "AuthAccessManagement", Migration0020],
70 [21, "AuthSessionClientMetadata", Migration0021],
71 [22, "AuthSessionLastConnectedAt", Migration0022],
6672] as const;
6773
6874export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts created+42
......@@ -0,0 +1,42 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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 @@
1import { Option, Schema, ServiceMap } from "effect";
2import type { Effect } from "effect";
3
4import type { AuthPairingLinkRepositoryError } from "../Errors.ts";
5
6export 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});
18export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type;
19
20export 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});
30export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type;
31
32export const ConsumeAuthPairingLinkInput = Schema.Struct({
33 credential: Schema.String,
34 consumedAt: Schema.DateTimeUtcFromString,
35 now: Schema.DateTimeUtcFromString,
36});
37export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type;
38
39export const ListActiveAuthPairingLinksInput = Schema.Struct({
40 now: Schema.DateTimeUtcFromString,
41});
42export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type;
43
44export const RevokeAuthPairingLinkInput = Schema.Struct({
45 id: Schema.String,
46 revokedAt: Schema.DateTimeUtcFromString,
47});
48export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type;
49
50export const GetAuthPairingLinkByCredentialInput = Schema.Struct({
51 credential: Schema.String,
52});
53export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type;
54
55export 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
73export 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 @@
1import { AuthClientMetadataDeviceType, AuthSessionId } from "@t3tools/contracts";
2import { Option, Schema, ServiceMap } from "effect";
3import type { Effect } from "effect";
4
5import type { AuthSessionRepositoryError } from "../Errors.ts";
6
7export 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});
15export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type;
16
17export 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});
28export type AuthSessionRecord = typeof AuthSessionRecord.Type;
29
30export 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});
39export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type;
40
41export const GetAuthSessionByIdInput = Schema.Struct({
42 sessionId: AuthSessionId,
43});
44export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type;
45
46export const ListActiveAuthSessionsInput = Schema.Struct({
47 now: Schema.DateTimeUtcFromString,
48});
49export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type;
50
51export const RevokeAuthSessionInput = Schema.Struct({
52 sessionId: AuthSessionId,
53 revokedAt: Schema.DateTimeUtcFromString,
54});
55export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type;
56
57export const RevokeOtherAuthSessionsInput = Schema.Struct({
58 currentSessionId: AuthSessionId,
59 revokedAt: Schema.DateTimeUtcFromString,
60});
61export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type;
62
63export const SetAuthSessionLastConnectedAtInput = Schema.Struct({
64 sessionId: AuthSessionId,
65 lastConnectedAt: Schema.DateTimeUtcFromString,
66});
67export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type;
68
69export 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
90export 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 {
4343} from "effect/unstable/http";
4444import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
4545import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
46import * as Socket from "effect/unstable/socket/Socket";
4647import { vi } from "vitest";
4748
4849import type { ServerConfigShape } from "./config.ts";
......@@ -68,6 +69,7 @@ import {
6869 type ProjectionSnapshotQueryShape,
6970} from "./orchestration/Services/ProjectionSnapshotQuery.ts";
7071import { PersistenceSqlError } from "./persistence/Errors.ts";
72import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
7173import {
7274 ProviderRegistry,
7375 type ProviderRegistryShape,
......@@ -96,9 +98,12 @@ import {
9698import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
9799import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
98100import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
101import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
102import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
99103
100104const defaultProjectId = ProjectId.makeUnsafe("project-default");
101105const defaultThreadId = ThreadId.makeUnsafe("thread-default");
106const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token";
102107const defaultModelSelection = {
103108 provider: "codex",
104109 model: "gpt-5-codex",
......@@ -115,7 +120,6 @@ const testEnvironmentDescriptor = {
115120 repositoryIdentity: true,
116121 },
117122};
118
119123const makeDefaultOrchestrationReadModel = () => {
120124 const now = new Date().toISOString();
121125 return {
......@@ -174,6 +178,11 @@ const browserOtlpTracingLayer = Layer.mergeAll(
174178 Layer.succeed(HttpClient.TracerDisabledWhen, () => true),
175179);
176180
181const authTestLayer = ServerAuthLive.pipe(
182 Layer.provide(SqlitePersistenceMemory),
183 Layer.provide(ServerSecretStoreLive),
184);
185
177186const makeBrowserOtlpPayload = (spanName: string) =>
178187 Effect.gen(function* () {
179188 const collector = yield* Effect.acquireRelease(
......@@ -313,7 +322,7 @@ const buildAppUnderTest = (options?: {
313322 otlpMetricsUrl: undefined,
314323 otlpExportIntervalMs: 10_000,
315324 otlpServiceName: "t3-server",
316 mode: "web",
325 mode: "desktop",
317326 port: 0,
318327 host: "127.0.0.1",
319328 cwd: process.cwd(),
......@@ -322,7 +331,7 @@ const buildAppUnderTest = (options?: {
322331 staticDir: undefined,
323332 devUrl,
324333 noBrowser: true,
325 authToken: undefined,
334 desktopBootstrapToken: defaultDesktopBootstrapToken,
326335 autoBootstrapProjectFromCwd: false,
327336 logWebSocketEvents: false,
328337 ...options?.config,
......@@ -333,12 +342,16 @@ const buildAppUnderTest = (options?: {
333342 });
334343 const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer));
335344
336 const appLayer = HttpRouter.serve(makeRoutesLayer, {
345 const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, {
337346 disableListenLog: true,
338347 disableLogger: true,
339348 }).pipe(
340349 Layer.provide(
341350 Layer.mock(Keybindings)({
351 loadConfigState: Effect.succeed({
352 keybindings: [],
353 issues: [],
354 }),
342355 streamChanges: Stream.empty,
343356 ...options?.layers?.keybindings,
344357 }),
......@@ -418,6 +431,9 @@ const buildAppUnderTest = (options?: {
418431 ...options?.layers?.checkpointDiffQuery,
419432 }),
420433 ),
434 );
435
436 const appLayer = servedRoutesLayer.pipe(
421437 Layer.provide(
422438 Layer.mock(BrowserTraceCollector)({
423439 record: () => Effect.void,
......@@ -453,6 +469,7 @@ const buildAppUnderTest = (options?: {
453469 ...options?.layers?.repositoryIdentityResolver,
454470 }),
455471 ),
472 Layer.provideMerge(authTestLayer),
456473 Layer.provide(workspaceAndProjectServicesLayer),
457474 Layer.provideMerge(FetchHttpClient.layer),
458475 Layer.provide(layerConfig),
......@@ -462,11 +479,37 @@ const buildAppUnderTest = (options?: {
462479 return config;
463480 });
464481
465const wsRpcProtocolLayer = (wsUrl: string) =>
466 RpcClient.layerProtocolSocket().pipe(
467 Layer.provide(NodeSocket.layerWebSocket(wsUrl)),
482const parseSessionCookieFromWsUrl = (
483 wsUrl: string,
484): { readonly cookie: string | null; readonly url: string } => {
485 const next = new URL(wsUrl);
486 const cookie = next.hash.startsWith("#cookie=")
487 ? decodeURIComponent(next.hash.slice("#cookie=".length))
488 : null;
489 next.hash = "";
490 return {
491 cookie,
492 url: next.toString(),
493 };
494};
495
496const 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))),
468510 Layer.provide(RpcSerialization.layerJson),
469511 );
512};
470513
471514const makeWsRpcClient = RpcClient.make(WsRpcGroup);
472515type WsRpcClient =
......@@ -477,6 +520,13 @@ const withWsRpcClient = <A, E, R>(
477520 f: (client: WsRpcClient) => Effect.Effect<A, E, R>,
478521) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl)));
479522
523const 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
480530const getHttpServerUrl = (pathname = "") =>
481531 Effect.gen(function* () {
482532 const server = yield* HttpServer.HttpServer;
......@@ -484,11 +534,130 @@ const getHttpServerUrl = (pathname = "") =>
484534 return `http://127.0.0.1:${address.port}${pathname}`;
485535 });
486536
487const getWsServerUrl = (pathname = "") =>
537const 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
569const 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
596const 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
612const 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
630const 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
639const splitHeaderTokens = (value: string | null) =>
640 (value ?? "")
641 .split(",")
642 .map((token) => token.trim())
643 .filter((token) => token.length > 0)
644 .toSorted();
645
646const getWsServerUrl = (
647 pathname = "",
648 options?: { authenticated?: boolean; credential?: string },
649) =>
488650 Effect.gen(function* () {
489651 const server = yield* HttpServer.HttpServer;
490652 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 );
492661 });
493662
494663it.layer(NodeServices.layer)("server router seam", (it) => {
......@@ -514,11 +683,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
514683 config: { devUrl: new URL("http://127.0.0.1:5173") },
515684 });
516685
517 const url = yield* getHttpServerUrl("/foo/bar");
686 const url = yield* getHttpServerUrl("/foo/bar?token=test-token");
518687 const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" }));
519688
520689 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 );
522694 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
523695 );
524696
......@@ -540,6 +712,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
540712
541713 const response = yield* HttpClient.get(
542714 `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
715 {
716 headers: {
717 cookie: yield* getAuthenticatedSessionCookieHeader(),
718 },
719 },
543720 );
544721
545722 assert.equal(response.status, 200);
......@@ -560,6 +737,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
560737
561738 const response = yield* HttpClient.get(
562739 `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
740 {
741 headers: {
742 cookie: yield* getAuthenticatedSessionCookieHeader(),
743 },
744 },
563745 );
564746
565747 assert.equal(response.status, 200);
......@@ -567,6 +749,597 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
567749 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
568750 );
569751
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
5701343 it.effect("serves attachment files from state dir", () =>
5711344 Effect.gen(function* () {
5721345 const fileSystem = yield* FileSystem.FileSystem;
......@@ -583,7 +1356,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5831356 yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
5841357 yield* fileSystem.writeFileString(attachmentPath, "attachment-ok");
5851358
586 const response = yield* HttpClient.get(`/attachments/${attachmentId}`);
1359 const response = yield* HttpClient.get(`/attachments/${attachmentId}`, {
1360 headers: {
1361 cookie: yield* getAuthenticatedSessionCookieHeader(),
1362 },
1363 });
5871364 assert.equal(response.status, 200);
5881365 assert.equal(yield* response.text, "attachment-ok");
5891366 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
......@@ -606,6 +1383,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6061383
6071384 const response = yield* HttpClient.get(
6081385 "/attachments/thread%20folder/message%20folder/file%20name.png",
1386 {
1387 headers: {
1388 cookie: yield* getAuthenticatedSessionCookieHeader(),
1389 },
1390 },
6091391 );
6101392 assert.equal(response.status, 200);
6111393 assert.equal(yield* response.text, "attachment-encoded-ok");
......@@ -742,6 +1524,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
7421524
7431525 const response = yield* HttpClient.post("/api/observability/v1/traces", {
7441526 headers: {
1527 cookie: yield* getAuthenticatedSessionCookieHeader(),
7451528 "content-type": "application/json",
7461529 origin: "http://localhost:5733",
7471530 },
......@@ -815,8 +1598,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
8151598
8161599 assert.equal(response.status, 204);
8171600 assert.equal(response.headers.get("access-control-allow-origin"), "*");
818 assert.equal(response.headers.get("access-control-allow-methods"), "POST, OPTIONS");
819 assert.equal(response.headers.get("access-control-allow-headers"), "content-type");
1601 assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [
1602 "GET",
1603 "OPTIONS",
1604 "POST",
1605 ]);
1606 assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [
1607 "authorization",
1608 "b3",
1609 "content-type",
1610 "traceparent",
1611 ]);
8201612 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8211613 );
8221614
......@@ -850,6 +1642,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
8501642
8511643 const response = yield* HttpClient.post("/api/observability/v1/traces", {
8521644 headers: {
1645 cookie: yield* getAuthenticatedSessionCookieHeader(),
8531646 "content-type": "application/json",
8541647 },
8551648 body: HttpBody.text(JSON.stringify(payload), "application/json"),
......@@ -897,6 +1690,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
8971690
8981691 const response = yield* HttpClient.get(
8991692 "/attachments/missing-11111111-1111-4111-8111-111111111111",
1693 {
1694 headers: {
1695 cookie: yield* getAuthenticatedSessionCookieHeader(),
1696 },
1697 },
9001698 );
9011699 assert.equal(response.status, 404);
9021700 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
......@@ -938,7 +1736,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
9381736 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
9391737 );
9401738
941 it.effect("rejects websocket rpc handshake when auth token is missing", () =>
1739 it.effect("rejects websocket rpc handshake when session authentication is missing", () =>
9421740 Effect.gen(function* () {
9431741 const fs = yield* FileSystem.FileSystem;
9441742 const path = yield* Path.Path;
......@@ -948,13 +1746,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
9481746 "export const needle = 1;",
9491747 );
9501748
951 yield* buildAppUnderTest({
952 config: {
953 authToken: "secret-token",
954 },
955 });
1749 yield* buildAppUnderTest();
9561750
957 const wsUrl = yield* getWsServerUrl("/ws");
1751 const wsUrl = yield* getWsServerUrl("/ws", { authenticated: false });
9581752 const result = yield* Effect.scoped(
9591753 withWsRpcClient(wsUrl, (client) =>
9601754 client[WS_METHODS.projectsSearchEntries]({
......@@ -970,38 +1764,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
9701764 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
9711765 );
9721766
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
10051767 it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
10061768 Effect.gen(function* () {
10071769 const providers = [] as const;
apps/server/src/server.ts+34-1
......@@ -6,7 +6,9 @@ import {
66 attachmentsRouteLayer,
77 otlpTracesProxyRouteLayer,
88 projectFaviconRouteLayer,
9 serverEnvironmentRouteLayer,
910 staticAndDevRouteLayer,
11 browserApiCorsLayer,
1012} from "./http";
1113import { fixPath } from "./os-jank";
1214import { websocketRpcRouteLayer } from "./ws";
......@@ -51,6 +53,20 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
5153import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner";
5254import { ObservabilityLive } from "./observability/Layers/Observability";
5355import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment";
56import {
57 authBearerBootstrapRouteLayer,
58 authBootstrapRouteLayer,
59 authClientsRevokeOthersRouteLayer,
60 authClientsRevokeRouteLayer,
61 authClientsRouteLayer,
62 authPairingLinksRevokeRouteLayer,
63 authPairingLinksRouteLayer,
64 authPairingCredentialRouteLayer,
65 authSessionRouteLayer,
66 authWebSocketTokenRouteLayer,
67} from "./auth/http";
68import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore";
69import { ServerAuthLive } from "./auth/Layers/ServerAuth";
5470
5571const PtyAdapterLive = Layer.unwrap(
5672 Effect.gen(function* () {
......@@ -188,6 +204,11 @@ const WorkspaceLayerLive = Layer.mergeAll(
188204 ),
189205);
190206
207const AuthLayerLive = ServerAuthLive.pipe(
208 Layer.provideMerge(PersistenceLayerLive),
209 Layer.provide(ServerSecretStoreLive),
210);
211
191212const RuntimeDependenciesLive = ReactorLayerLive.pipe(
192213 // Core Services
193214 Layer.provideMerge(CheckpointingLayerLive),
......@@ -203,6 +224,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe(
203224 Layer.provideMerge(ProjectFaviconResolverLive),
204225 Layer.provideMerge(RepositoryIdentityResolverLive),
205226 Layer.provideMerge(ServerEnvironmentLive),
227 Layer.provideMerge(AuthLayerLive),
206228
207229 // Misc.
208230 Layer.provideMerge(AnalyticsServiceLayerLive),
......@@ -215,12 +237,23 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
215237);
216238
217239export const makeRoutesLayer = Layer.mergeAll(
240 authBearerBootstrapRouteLayer,
241 authBootstrapRouteLayer,
242 authClientsRevokeOthersRouteLayer,
243 authClientsRevokeRouteLayer,
244 authClientsRouteLayer,
245 authPairingLinksRevokeRouteLayer,
246 authPairingLinksRouteLayer,
247 authPairingCredentialRouteLayer,
248 authSessionRouteLayer,
249 authWebSocketTokenRouteLayer,
218250 attachmentsRouteLayer,
219251 otlpTracesProxyRouteLayer,
220252 projectFaviconRouteLayer,
253 serverEnvironmentRouteLayer,
221254 staticAndDevRouteLayer,
222255 websocketRpcRouteLayer,
223);
256).pipe(Layer.provide(browserApiCorsLayer));
224257
225258export const makeServerLayer = Layer.unwrap(
226259 Effect.gen(function* () {
apps/server/src/serverRuntimeStartup.test.ts+9
......@@ -1,14 +1,23 @@
1import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts";
12import { assert, it } from "@effect/vitest";
23import { Deferred, Effect, Fiber, Option, Ref } from "effect";
34
45import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
56import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
67import {
8 getAutoBootstrapDefaultModelSelection,
79 launchStartupHeartbeat,
810 makeCommandGate,
911 ServerRuntimeStartupError,
1012} from "./serverRuntimeStartup.ts";
1113
14it("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
1221it.effect("enqueueCommand waits for readiness and then drains queued work", () =>
1322 Effect.scoped(
1423 Effect.gen(function* () {
apps/server/src/serverRuntimeStartup.ts+40-21
......@@ -1,5 +1,6 @@
11import {
22 CommandId,
3 DEFAULT_MODEL_BY_PROVIDER,
34 DEFAULT_PROVIDER_INTERACTION_MODE,
45 type ModelSelection,
56 ProjectId,
......@@ -29,6 +30,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents";
2930import { ServerSettingsService } from "./serverSettings";
3031import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
3132import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
33import { ServerAuth } from "./auth/Services/ServerAuth";
3234
3335const isWildcardHost = (host: string | undefined): boolean =>
3436 host === "0.0.0.0" || host === "::" || host === "[::]";
......@@ -149,6 +151,11 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe(
149151 Effect.asVoid,
150152);
151153
154export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({
155 provider: "codex",
156 model: DEFAULT_MODEL_BY_PROVIDER.codex,
157});
158
152159const autoBootstrapWelcome = Effect.gen(function* () {
153160 const serverConfig = yield* ServerConfig;
154161 const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
......@@ -170,10 +177,7 @@ const autoBootstrapWelcome = Effect.gen(function* () {
170177 const createdAt = new Date().toISOString();
171178 nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID());
172179 const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";
173 nextProjectDefaultModelSelection = {
174 provider: "codex",
175 model: "gpt-5-codex",
176 };
180 nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection();
177181 yield* orchestrationEngine.dispatch({
178182 type: "project.create",
179183 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
......@@ -185,10 +189,8 @@ const autoBootstrapWelcome = Effect.gen(function* () {
185189 });
186190 } else {
187191 nextProjectId = existingProject.value.id;
188 nextProjectDefaultModelSelection = existingProject.value.defaultModelSelection ?? {
189 provider: "codex",
190 model: "gpt-5-codex",
191 };
192 nextProjectDefaultModelSelection =
193 existingProject.value.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection();
192194 }
193195
194196 const existingThreadId =
......@@ -229,28 +231,39 @@ const autoBootstrapWelcome = Effect.gen(function* () {
229231 } as const;
230232});
231233
232const maybeOpenBrowser = Effect.gen(function* () {
234const resolveStartupBrowserTarget = Effect.gen(function* () {
233235 const serverConfig = yield* ServerConfig;
234 if (serverConfig.noBrowser) {
235 return;
236 }
237 const { openBrowser } = yield* Open;
236 const serverAuth = yield* ServerAuth;
238237 const localUrl = `http://localhost:${serverConfig.port}`;
239238 const bindUrl =
240239 serverConfig.host && !isWildcardHost(serverConfig.host)
241240 ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}`
242241 : localUrl;
243 const target = serverConfig.devUrl?.toString() ?? bindUrl;
244
245 yield* openBrowser(target).pipe(
246 Effect.catch(() =>
247 Effect.logInfo("browser auto-open unavailable", {
248 hint: `Open ${target} in your browser.`,
249 }),
242 const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl;
243 return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe(
244 Effect.flatMap((target) =>
245 target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget),
250246 ),
251247 );
252248});
253249
250const 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
254267const runStartupPhase = <A, E, R>(phase: string, effect: Effect.Effect<A, E, R>) =>
255268 effect.pipe(
256269 Effect.annotateSpans({ "startup.phase": phase }),
......@@ -371,7 +384,13 @@ const makeServerRuntimeStartup = Effect.gen(function* () {
371384 yield* Effect.logDebug("startup phase: recording startup heartbeat");
372385 yield* launchStartupHeartbeat;
373386 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));
375394 yield* Effect.logDebug("startup phase: complete");
376395 }),
377396 );
apps/server/src/ws.ts+833-728
......@@ -1,5 +1,7 @@
1import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect";
1import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect";
22import {
3 type AuthAccessStreamEvent,
4 AuthSessionId,
35 CommandId,
46 EventId,
57 type OrchestrationCommand,
......@@ -20,7 +22,7 @@ import {
2022 WsRpcGroup,
2123} from "@t3tools/contracts";
2224import { clamp } from "effect/Number";
23import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
25import { HttpRouter, HttpServerRequest } from "effect/unstable/http";
2426import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
2527
2628import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
......@@ -49,791 +51,894 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat
4951import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner";
5052import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver";
5153import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
54import { ServerAuth } from "./auth/Services/ServerAuth";
55import {
56 BootstrapCredentialService,
57 type BootstrapCredentialChange,
58} from "./auth/Services/BootstrapCredentialService";
59import {
60 SessionCredentialService,
61 type SessionCredentialChange,
62} from "./auth/Services/SessionCredentialService";
63import { respondToAuthError } from "./auth/http";
5264
53const WsRpcLayer = WsRpcGroup.toLayer(
54 Effect.gen(function* () {
55 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
56 const orchestrationEngine = yield* OrchestrationEngineService;
57 const checkpointDiffQuery = yield* CheckpointDiffQuery;
58 const keybindings = yield* Keybindings;
59 const open = yield* Open;
60 const gitManager = yield* GitManager;
61 const git = yield* GitCore;
62 const gitStatusBroadcaster = yield* GitStatusBroadcaster;
63 const terminalManager = yield* TerminalManager;
64 const providerRegistry = yield* ProviderRegistry;
65 const config = yield* ServerConfig;
66 const lifecycleEvents = yield* ServerLifecycleEvents;
67 const serverSettings = yield* ServerSettingsService;
68 const startup = yield* ServerRuntimeStartup;
69 const workspaceEntries = yield* WorkspaceEntries;
70 const workspaceFileSystem = yield* WorkspaceFileSystem;
71 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
72 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
73 const serverEnvironment = yield* ServerEnvironment;
74 const serverCommandId = (tag: string) =>
75 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
76
77 const appendSetupScriptActivity = (input: {
78 readonly threadId: ThreadId;
79 readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed";
80 readonly summary: string;
81 readonly createdAt: string;
82 readonly payload: Record<string, unknown>;
83 readonly tone: "info" | "error";
84 }) =>
85 orchestrationEngine.dispatch({
86 type: "thread.activity.append",
87 commandId: serverCommandId("setup-script-activity"),
88 threadId: input.threadId,
89 activity: {
90 id: EventId.makeUnsafe(crypto.randomUUID()),
91 tone: input.tone,
92 kind: input.kind,
93 summary: input.summary,
94 payload: input.payload,
95 turnId: null,
96 createdAt: input.createdAt,
65function toAuthAccessStreamEvent(
66 change: BootstrapCredentialChange | SessionCredentialChange,
67 revision: number,
68 currentSessionId: AuthSessionId,
69): AuthAccessStreamEvent {
70 switch (change.type) {
71 case "pairingLinkUpserted":
72 return {
73 version: 1,
74 revision,
75 type: "pairingLinkUpserted",
76 payload: change.pairingLink,
77 };
78 case "pairingLinkRemoved":
79 return {
80 version: 1,
81 revision,
82 type: "pairingLinkRemoved",
83 payload: { id: change.id },
84 };
85 case "clientUpserted":
86 return {
87 version: 1,
88 revision,
89 type: "clientUpserted",
90 payload: {
91 ...change.clientSession,
92 current: change.clientSession.sessionId === currentSessionId,
9793 },
98 createdAt: input.createdAt,
99 });
94 };
95 case "clientRemoved":
96 return {
97 version: 1,
98 revision,
99 type: "clientRemoved",
100 payload: { sessionId: change.sessionId },
101 };
102 }
103}
100104
101 const toDispatchCommandError = (cause: unknown, fallbackMessage: string) =>
102 Schema.is(OrchestrationDispatchCommandError)(cause)
103 ? cause
104 : new OrchestrationDispatchCommandError({
105 message: cause instanceof Error ? cause.message : fallbackMessage,
106 cause,
107 });
105const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
106 WsRpcGroup.toLayer(
107 Effect.gen(function* () {
108 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
109 const orchestrationEngine = yield* OrchestrationEngineService;
110 const checkpointDiffQuery = yield* CheckpointDiffQuery;
111 const keybindings = yield* Keybindings;
112 const open = yield* Open;
113 const gitManager = yield* GitManager;
114 const git = yield* GitCore;
115 const gitStatusBroadcaster = yield* GitStatusBroadcaster;
116 const terminalManager = yield* TerminalManager;
117 const providerRegistry = yield* ProviderRegistry;
118 const config = yield* ServerConfig;
119 const lifecycleEvents = yield* ServerLifecycleEvents;
120 const serverSettings = yield* ServerSettingsService;
121 const startup = yield* ServerRuntimeStartup;
122 const workspaceEntries = yield* WorkspaceEntries;
123 const workspaceFileSystem = yield* WorkspaceFileSystem;
124 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
125 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
126 const serverEnvironment = yield* ServerEnvironment;
127 const serverAuth = yield* ServerAuth;
128 const bootstrapCredentials = yield* BootstrapCredentialService;
129 const sessions = yield* SessionCredentialService;
130 const serverCommandId = (tag: string) =>
131 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
108132
109 const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause<unknown>) => {
110 const error = Cause.squash(cause);
111 return Schema.is(OrchestrationDispatchCommandError)(error)
112 ? error
113 : new OrchestrationDispatchCommandError({
114 message:
115 error instanceof Error ? error.message : "Failed to bootstrap thread turn start.",
116 cause,
117 });
118 };
119
120 const enrichProjectEvent = (
121 event: OrchestrationEvent,
122 ): Effect.Effect<OrchestrationEvent, never, never> => {
123 switch (event.type) {
124 case "project.created":
125 return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe(
126 Effect.map((repositoryIdentity) => ({
127 ...event,
128 payload: {
129 ...event.payload,
130 repositoryIdentity,
131 },
132 })),
133 );
134 case "project.meta-updated":
135 return Effect.gen(function* () {
136 const workspaceRoot =
137 event.payload.workspaceRoot ??
138 (yield* orchestrationEngine.getReadModel()).projects.find(
139 (project) => project.id === event.payload.projectId,
140 )?.workspaceRoot ??
141 null;
142 if (workspaceRoot === null) {
143 return event;
144 }
133 const loadAuthAccessSnapshot = () =>
134 Effect.all({
135 pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie),
136 clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie),
137 });
145138
146 const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot);
147 return {
148 ...event,
149 payload: {
150 ...event.payload,
151 repositoryIdentity,
152 },
153 } satisfies OrchestrationEvent;
154 });
155 default:
156 return Effect.succeed(event);
157 }
158 };
139 const appendSetupScriptActivity = (input: {
140 readonly threadId: ThreadId;
141 readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed";
142 readonly summary: string;
143 readonly createdAt: string;
144 readonly payload: Record<string, unknown>;
145 readonly tone: "info" | "error";
146 }) =>
147 orchestrationEngine.dispatch({
148 type: "thread.activity.append",
149 commandId: serverCommandId("setup-script-activity"),
150 threadId: input.threadId,
151 activity: {
152 id: EventId.makeUnsafe(crypto.randomUUID()),
153 tone: input.tone,
154 kind: input.kind,
155 summary: input.summary,
156 payload: input.payload,
157 turnId: null,
158 createdAt: input.createdAt,
159 },
160 createdAt: input.createdAt,
161 });
159162
160 const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) =>
161 Effect.forEach(events, enrichProjectEvent, { concurrency: 4 });
163 const toDispatchCommandError = (cause: unknown, fallbackMessage: string) =>
164 Schema.is(OrchestrationDispatchCommandError)(cause)
165 ? cause
166 : new OrchestrationDispatchCommandError({
167 message: cause instanceof Error ? cause.message : fallbackMessage,
168 cause,
169 });
162170
163 const dispatchBootstrapTurnStart = (
164 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,
165 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
166 Effect.gen(function* () {
167 const bootstrap = command.bootstrap;
168 const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command;
169 let createdThread = false;
170 let targetProjectId = bootstrap?.createThread?.projectId;
171 let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd;
172 let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null;
173
174 const cleanupCreatedThread = () =>
175 createdThread
176 ? orchestrationEngine
177 .dispatch({
178 type: "thread.delete",
179 commandId: serverCommandId("bootstrap-thread-delete"),
180 threadId: command.threadId,
181 })
182 .pipe(Effect.ignoreCause({ log: true }))
183 : Effect.void;
184
185 const recordSetupScriptLaunchFailure = (input: {
186 readonly error: unknown;
187 readonly requestedAt: string;
188 readonly worktreePath: string;
189 }) => {
190 const detail =
191 input.error instanceof Error ? input.error.message : "Unknown setup failure.";
192 return appendSetupScriptActivity({
193 threadId: command.threadId,
194 kind: "setup-script.failed",
195 summary: "Setup script failed to start",
196 createdAt: input.requestedAt,
197 payload: {
198 detail,
199 worktreePath: input.worktreePath,
200 },
201 tone: "error",
202 }).pipe(
203 Effect.ignoreCause({ log: false }),
204 Effect.flatMap(() =>
205 Effect.logWarning("bootstrap turn start failed to launch setup script", {
206 threadId: command.threadId,
207 worktreePath: input.worktreePath,
208 detail,
209 }),
210 ),
211 );
212 };
171 const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause<unknown>) => {
172 const error = Cause.squash(cause);
173 return Schema.is(OrchestrationDispatchCommandError)(error)
174 ? error
175 : new OrchestrationDispatchCommandError({
176 message:
177 error instanceof Error ? error.message : "Failed to bootstrap thread turn start.",
178 cause,
179 });
180 };
213181
214 const recordSetupScriptStarted = (input: {
215 readonly requestedAt: string;
216 readonly worktreePath: string;
217 readonly scriptId: string;
218 readonly scriptName: string;
219 readonly terminalId: string;
220 }) => {
221 const payload = {
222 scriptId: input.scriptId,
223 scriptName: input.scriptName,
224 terminalId: input.terminalId,
225 worktreePath: input.worktreePath,
226 };
227 return Effect.all([
228 appendSetupScriptActivity({
182 const enrichProjectEvent = (
183 event: OrchestrationEvent,
184 ): Effect.Effect<OrchestrationEvent, never, never> => {
185 switch (event.type) {
186 case "project.created":
187 return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe(
188 Effect.map((repositoryIdentity) => ({
189 ...event,
190 payload: {
191 ...event.payload,
192 repositoryIdentity,
193 },
194 })),
195 );
196 case "project.meta-updated":
197 return Effect.gen(function* () {
198 const workspaceRoot =
199 event.payload.workspaceRoot ??
200 (yield* orchestrationEngine.getReadModel()).projects.find(
201 (project) => project.id === event.payload.projectId,
202 )?.workspaceRoot ??
203 null;
204 if (workspaceRoot === null) {
205 return event;
206 }
207
208 const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot);
209 return {
210 ...event,
211 payload: {
212 ...event.payload,
213 repositoryIdentity,
214 },
215 } satisfies OrchestrationEvent;
216 });
217 default:
218 return Effect.succeed(event);
219 }
220 };
221
222 const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) =>
223 Effect.forEach(events, enrichProjectEvent, { concurrency: 4 });
224
225 const dispatchBootstrapTurnStart = (
226 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,
227 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
228 Effect.gen(function* () {
229 const bootstrap = command.bootstrap;
230 const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command;
231 let createdThread = false;
232 let targetProjectId = bootstrap?.createThread?.projectId;
233 let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd;
234 let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null;
235
236 const cleanupCreatedThread = () =>
237 createdThread
238 ? orchestrationEngine
239 .dispatch({
240 type: "thread.delete",
241 commandId: serverCommandId("bootstrap-thread-delete"),
242 threadId: command.threadId,
243 })
244 .pipe(Effect.ignoreCause({ log: true }))
245 : Effect.void;
246
247 const recordSetupScriptLaunchFailure = (input: {
248 readonly error: unknown;
249 readonly requestedAt: string;
250 readonly worktreePath: string;
251 }) => {
252 const detail =
253 input.error instanceof Error ? input.error.message : "Unknown setup failure.";
254 return appendSetupScriptActivity({
229255 threadId: command.threadId,
230 kind: "setup-script.requested",
231 summary: "Starting setup script",
256 kind: "setup-script.failed",
257 summary: "Setup script failed to start",
232258 createdAt: input.requestedAt,
233 payload,
234 tone: "info",
235 }),
236 appendSetupScriptActivity({
237 threadId: command.threadId,
238 kind: "setup-script.started",
239 summary: "Setup script started",
240 createdAt: new Date().toISOString(),
241 payload,
242 tone: "info",
243 }),
244 ]).pipe(
245 Effect.asVoid,
246 Effect.catch((error) =>
247 Effect.logWarning(
248 "bootstrap turn start launched setup script but failed to record setup activity",
249 {
259 payload: {
260 detail,
261 worktreePath: input.worktreePath,
262 },
263 tone: "error",
264 }).pipe(
265 Effect.ignoreCause({ log: false }),
266 Effect.flatMap(() =>
267 Effect.logWarning("bootstrap turn start failed to launch setup script", {
250268 threadId: command.threadId,
251269 worktreePath: input.worktreePath,
252 scriptId: input.scriptId,
253 terminalId: input.terminalId,
254 detail:
255 error instanceof Error
256 ? error.message
257 : "Unknown setup activity dispatch failure.",
258 },
270 detail,
271 }),
259272 ),
260 ),
261 );
262 };
273 );
274 };
263275
264 const runSetupProgram = () =>
265 bootstrap?.runSetupScript && targetWorktreePath
266 ? (() => {
267 const worktreePath = targetWorktreePath;
268 const requestedAt = new Date().toISOString();
269 return projectSetupScriptRunner
270 .runForThread({
276 const recordSetupScriptStarted = (input: {
277 readonly requestedAt: string;
278 readonly worktreePath: string;
279 readonly scriptId: string;
280 readonly scriptName: string;
281 readonly terminalId: string;
282 }) => {
283 const payload = {
284 scriptId: input.scriptId,
285 scriptName: input.scriptName,
286 terminalId: input.terminalId,
287 worktreePath: input.worktreePath,
288 };
289 return Effect.all([
290 appendSetupScriptActivity({
291 threadId: command.threadId,
292 kind: "setup-script.requested",
293 summary: "Starting setup script",
294 createdAt: input.requestedAt,
295 payload,
296 tone: "info",
297 }),
298 appendSetupScriptActivity({
299 threadId: command.threadId,
300 kind: "setup-script.started",
301 summary: "Setup script started",
302 createdAt: new Date().toISOString(),
303 payload,
304 tone: "info",
305 }),
306 ]).pipe(
307 Effect.asVoid,
308 Effect.catch((error) =>
309 Effect.logWarning(
310 "bootstrap turn start launched setup script but failed to record setup activity",
311 {
271312 threadId: command.threadId,
272 ...(targetProjectId ? { projectId: targetProjectId } : {}),
273 ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}),
274 worktreePath,
275 })
276 .pipe(
277 Effect.matchEffect({
278 onFailure: (error) =>
279 recordSetupScriptLaunchFailure({
280 error,
281 requestedAt,
282 worktreePath,
283 }),
284 onSuccess: (setupResult) => {
285 if (setupResult.status !== "started") {
286 return Effect.void;
287 }
288 return recordSetupScriptStarted({
289 requestedAt,
290 worktreePath,
291 scriptId: setupResult.scriptId,
292 scriptName: setupResult.scriptName,
293 terminalId: setupResult.terminalId,
294 });
295 },
296 }),
297 );
298 })()
299 : Effect.void;
300
301 const bootstrapProgram = Effect.gen(function* () {
302 if (bootstrap?.createThread) {
303 yield* orchestrationEngine.dispatch({
304 type: "thread.create",
305 commandId: serverCommandId("bootstrap-thread-create"),
306 threadId: command.threadId,
307 projectId: bootstrap.createThread.projectId,
308 title: bootstrap.createThread.title,
309 modelSelection: bootstrap.createThread.modelSelection,
310 runtimeMode: bootstrap.createThread.runtimeMode,
311 interactionMode: bootstrap.createThread.interactionMode,
312 branch: bootstrap.createThread.branch,
313 worktreePath: bootstrap.createThread.worktreePath,
314 createdAt: bootstrap.createThread.createdAt,
315 });
316 createdThread = true;
317 }
318
319 if (bootstrap?.prepareWorktree) {
320 const worktree = yield* git.createWorktree({
321 cwd: bootstrap.prepareWorktree.projectCwd,
322 branch: bootstrap.prepareWorktree.baseBranch,
323 newBranch: bootstrap.prepareWorktree.branch,
324 path: null,
325 });
326 targetWorktreePath = worktree.worktree.path;
327 yield* orchestrationEngine.dispatch({
328 type: "thread.meta.update",
329 commandId: serverCommandId("bootstrap-thread-meta-update"),
330 threadId: command.threadId,
331 branch: worktree.worktree.branch,
332 worktreePath: targetWorktreePath,
333 });
334 }
313 worktreePath: input.worktreePath,
314 scriptId: input.scriptId,
315 terminalId: input.terminalId,
316 detail:
317 error instanceof Error
318 ? error.message
319 : "Unknown setup activity dispatch failure.",
320 },
321 ),
322 ),
323 );
324 };
335325
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;
337362
338 return yield* orchestrationEngine.dispatch(finalTurnStartCommand);
339 });
363 const bootstrapProgram = Effect.gen(function* () {
364 if (bootstrap?.createThread) {
365 yield* orchestrationEngine.dispatch({
366 type: "thread.create",
367 commandId: serverCommandId("bootstrap-thread-create"),
368 threadId: command.threadId,
369 projectId: bootstrap.createThread.projectId,
370 title: bootstrap.createThread.title,
371 modelSelection: bootstrap.createThread.modelSelection,
372 runtimeMode: bootstrap.createThread.runtimeMode,
373 interactionMode: bootstrap.createThread.interactionMode,
374 branch: bootstrap.createThread.branch,
375 worktreePath: bootstrap.createThread.worktreePath,
376 createdAt: bootstrap.createThread.createdAt,
377 });
378 createdThread = true;
379 }
340380
341 return yield* bootstrapProgram.pipe(
342 Effect.catchCause((cause) => {
343 const dispatchError = toBootstrapDispatchCommandCauseError(cause);
344 if (Cause.hasInterruptsOnly(cause)) {
345 return Effect.fail(dispatchError);
381 if (bootstrap?.prepareWorktree) {
382 const worktree = yield* git.createWorktree({
383 cwd: bootstrap.prepareWorktree.projectCwd,
384 branch: bootstrap.prepareWorktree.baseBranch,
385 newBranch: bootstrap.prepareWorktree.branch,
386 path: null,
387 });
388 targetWorktreePath = worktree.worktree.path;
389 yield* orchestrationEngine.dispatch({
390 type: "thread.meta.update",
391 commandId: serverCommandId("bootstrap-thread-meta-update"),
392 threadId: command.threadId,
393 branch: worktree.worktree.branch,
394 worktreePath: targetWorktreePath,
395 });
346396 }
347 return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError)));
348 }),
349 );
350 });
351397
352 const dispatchNormalizedCommand = (
353 normalizedCommand: OrchestrationCommand,
354 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => {
355 const dispatchEffect =
356 normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap
357 ? dispatchBootstrapTurnStart(normalizedCommand)
358 : orchestrationEngine
359 .dispatch(normalizedCommand)
360 .pipe(
361 Effect.mapError((cause) =>
362 toDispatchCommandError(cause, "Failed to dispatch orchestration command"),
363 ),
364 );
398 yield* runSetupProgram();
365399
366 return startup
367 .enqueueCommand(dispatchEffect)
368 .pipe(
369 Effect.mapError((cause) =>
370 toDispatchCommandError(cause, "Failed to dispatch orchestration command"),
371 ),
372 );
373 };
400 return yield* orchestrationEngine.dispatch(finalTurnStartCommand);
401 });
374402
375 const loadServerConfig = Effect.gen(function* () {
376 const keybindingsConfig = yield* keybindings.loadConfigState;
377 const providers = yield* providerRegistry.getProviders;
378 const settings = yield* serverSettings.getSettings;
379 const environment = yield* serverEnvironment.getDescriptor;
403 return yield* bootstrapProgram.pipe(
404 Effect.catchCause((cause) => {
405 const dispatchError = toBootstrapDispatchCommandCauseError(cause);
406 if (Cause.hasInterruptsOnly(cause)) {
407 return Effect.fail(dispatchError);
408 }
409 return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError)));
410 }),
411 );
412 });
380413
381 return {
382 environment,
383 cwd: config.cwd,
384 keybindingsConfigPath: config.keybindingsConfigPath,
385 keybindings: keybindingsConfig.keybindings,
386 issues: keybindingsConfig.issues,
387 providers,
388 availableEditors: resolveAvailableEditors(),
389 observability: {
390 logsDirectoryPath: config.logsDir,
391 localTracingEnabled: true,
392 ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}),
393 otlpTracesEnabled: config.otlpTracesUrl !== undefined,
394 ...(config.otlpMetricsUrl !== undefined ? { otlpMetricsUrl: config.otlpMetricsUrl } : {}),
395 otlpMetricsEnabled: config.otlpMetricsUrl !== undefined,
396 },
397 settings,
398 };
399 });
400
401 const refreshGitStatus = (cwd: string) =>
402 gitStatusBroadcaster
403 .refreshStatus(cwd)
404 .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid);
405
406 return WsRpcGroup.of({
407 [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) =>
408 observeRpcEffect(
409 ORCHESTRATION_WS_METHODS.getSnapshot,
410 projectionSnapshotQuery.getSnapshot().pipe(
411 Effect.mapError(
412 (cause) =>
413 new OrchestrationGetSnapshotError({
414 message: "Failed to load orchestration snapshot",
415 cause,
416 }),
417 ),
418 ),
419 { "rpc.aggregate": "orchestration" },
420 ),
421 [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) =>
422 observeRpcEffect(
423 ORCHESTRATION_WS_METHODS.dispatchCommand,
424 Effect.gen(function* () {
425 const normalizedCommand = yield* normalizeDispatchCommand(command);
426 const result = yield* dispatchNormalizedCommand(normalizedCommand);
427 if (normalizedCommand.type === "thread.archive") {
428 yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe(
429 Effect.catch((error) =>
430 Effect.logWarning("failed to close thread terminals after archive", {
431 threadId: normalizedCommand.threadId,
432 error: error.message,
433 }),
434 ),
435 );
436 }
437 return result;
438 }).pipe(
414 const dispatchNormalizedCommand = (
415 normalizedCommand: OrchestrationCommand,
416 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => {
417 const dispatchEffect =
418 normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap
419 ? dispatchBootstrapTurnStart(normalizedCommand)
420 : orchestrationEngine
421 .dispatch(normalizedCommand)
422 .pipe(
423 Effect.mapError((cause) =>
424 toDispatchCommandError(cause, "Failed to dispatch orchestration command"),
425 ),
426 );
427
428 return startup
429 .enqueueCommand(dispatchEffect)
430 .pipe(
439431 Effect.mapError((cause) =>
440 Schema.is(OrchestrationDispatchCommandError)(cause)
441 ? cause
442 : new OrchestrationDispatchCommandError({
443 message: "Failed to dispatch orchestration command",
432 toDispatchCommandError(cause, "Failed to dispatch orchestration command"),
433 ),
434 );
435 };
436
437 const loadServerConfig = Effect.gen(function* () {
438 const keybindingsConfig = yield* keybindings.loadConfigState;
439 const providers = yield* providerRegistry.getProviders;
440 const settings = yield* serverSettings.getSettings;
441 const environment = yield* serverEnvironment.getDescriptor;
442 const auth = yield* serverAuth.getDescriptor();
443
444 return {
445 environment,
446 auth,
447 cwd: config.cwd,
448 keybindingsConfigPath: config.keybindingsConfigPath,
449 keybindings: keybindingsConfig.keybindings,
450 issues: keybindingsConfig.issues,
451 providers,
452 availableEditors: resolveAvailableEditors(),
453 observability: {
454 logsDirectoryPath: config.logsDir,
455 localTracingEnabled: true,
456 ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}),
457 otlpTracesEnabled: config.otlpTracesUrl !== undefined,
458 ...(config.otlpMetricsUrl !== undefined
459 ? { otlpMetricsUrl: config.otlpMetricsUrl }
460 : {}),
461 otlpMetricsEnabled: config.otlpMetricsUrl !== undefined,
462 },
463 settings,
464 };
465 });
466
467 const refreshGitStatus = (cwd: string) =>
468 gitStatusBroadcaster
469 .refreshStatus(cwd)
470 .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid);
471
472 return WsRpcGroup.of({
473 [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) =>
474 observeRpcEffect(
475 ORCHESTRATION_WS_METHODS.getSnapshot,
476 projectionSnapshotQuery.getSnapshot().pipe(
477 Effect.mapError(
478 (cause) =>
479 new OrchestrationGetSnapshotError({
480 message: "Failed to load orchestration snapshot",
444481 cause,
445482 }),
483 ),
446484 ),
485 { "rpc.aggregate": "orchestration" },
447486 ),
448 { "rpc.aggregate": "orchestration" },
449 ),
450 [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) =>
451 observeRpcEffect(
452 ORCHESTRATION_WS_METHODS.getTurnDiff,
453 checkpointDiffQuery.getTurnDiff(input).pipe(
454 Effect.mapError(
455 (cause) =>
456 new OrchestrationGetTurnDiffError({
457 message: "Failed to load turn diff",
458 cause,
459 }),
487 [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) =>
488 observeRpcEffect(
489 ORCHESTRATION_WS_METHODS.dispatchCommand,
490 Effect.gen(function* () {
491 const normalizedCommand = yield* normalizeDispatchCommand(command);
492 const result = yield* dispatchNormalizedCommand(normalizedCommand);
493 if (normalizedCommand.type === "thread.archive") {
494 yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe(
495 Effect.catch((error) =>
496 Effect.logWarning("failed to close thread terminals after archive", {
497 threadId: normalizedCommand.threadId,
498 error: error.message,
499 }),
500 ),
501 );
502 }
503 return result;
504 }).pipe(
505 Effect.mapError((cause) =>
506 Schema.is(OrchestrationDispatchCommandError)(cause)
507 ? cause
508 : new OrchestrationDispatchCommandError({
509 message: "Failed to dispatch orchestration command",
510 cause,
511 }),
512 ),
460513 ),
514 { "rpc.aggregate": "orchestration" },
461515 ),
462 { "rpc.aggregate": "orchestration" },
463 ),
464 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) =>
465 observeRpcEffect(
466 ORCHESTRATION_WS_METHODS.getFullThreadDiff,
467 checkpointDiffQuery.getFullThreadDiff(input).pipe(
468 Effect.mapError(
469 (cause) =>
470 new OrchestrationGetFullThreadDiffError({
471 message: "Failed to load full thread diff",
472 cause,
473 }),
516 [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) =>
517 observeRpcEffect(
518 ORCHESTRATION_WS_METHODS.getTurnDiff,
519 checkpointDiffQuery.getTurnDiff(input).pipe(
520 Effect.mapError(
521 (cause) =>
522 new OrchestrationGetTurnDiffError({
523 message: "Failed to load turn diff",
524 cause,
525 }),
526 ),
474527 ),
528 { "rpc.aggregate": "orchestration" },
475529 ),
476 { "rpc.aggregate": "orchestration" },
477 ),
478 [ORCHESTRATION_WS_METHODS.replayEvents]: (input) =>
479 observeRpcEffect(
480 ORCHESTRATION_WS_METHODS.replayEvents,
481 Stream.runCollect(
482 orchestrationEngine.readEvents(
483 clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }),
484 ),
485 ).pipe(
486 Effect.map((events) => Array.from(events)),
487 Effect.flatMap(enrichOrchestrationEvents),
488 Effect.mapError(
489 (cause) =>
490 new OrchestrationReplayEventsError({
491 message: "Failed to replay orchestration events",
492 cause,
493 }),
530 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) =>
531 observeRpcEffect(
532 ORCHESTRATION_WS_METHODS.getFullThreadDiff,
533 checkpointDiffQuery.getFullThreadDiff(input).pipe(
534 Effect.mapError(
535 (cause) =>
536 new OrchestrationGetFullThreadDiffError({
537 message: "Failed to load full thread diff",
538 cause,
539 }),
540 ),
494541 ),
542 { "rpc.aggregate": "orchestration" },
495543 ),
496 { "rpc.aggregate": "orchestration" },
497 ),
498 [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) =>
499 observeRpcStreamEffect(
500 WS_METHODS.subscribeOrchestrationDomainEvents,
501 Effect.gen(function* () {
502 const snapshot = yield* orchestrationEngine.getReadModel();
503 const fromSequenceExclusive = snapshot.snapshotSequence;
504 const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect(
505 orchestrationEngine.readEvents(fromSequenceExclusive),
544 [ORCHESTRATION_WS_METHODS.replayEvents]: (input) =>
545 observeRpcEffect(
546 ORCHESTRATION_WS_METHODS.replayEvents,
547 Stream.runCollect(
548 orchestrationEngine.readEvents(
549 clamp(input.fromSequenceExclusive, {
550 maximum: Number.MAX_SAFE_INTEGER,
551 minimum: 0,
552 }),
553 ),
506554 ).pipe(
507555 Effect.map((events) => Array.from(events)),
508556 Effect.flatMap(enrichOrchestrationEvents),
509 Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
510 );
511 const replayStream = Stream.fromIterable(replayEvents);
512 const liveStream = orchestrationEngine.streamDomainEvents.pipe(
513 Stream.mapEffect(enrichProjectEvent),
514 );
515 const source = Stream.merge(replayStream, liveStream);
516 type SequenceState = {
517 readonly nextSequence: number;
518 readonly pendingBySequence: Map<number, OrchestrationEvent>;
519 };
520 const state = yield* Ref.make<SequenceState>({
521 nextSequence: fromSequenceExclusive + 1,
522 pendingBySequence: new Map<number, OrchestrationEvent>(),
523 });
557 Effect.mapError(
558 (cause) =>
559 new OrchestrationReplayEventsError({
560 message: "Failed to replay orchestration events",
561 cause,
562 }),
563 ),
564 ),
565 { "rpc.aggregate": "orchestration" },
566 ),
567 [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) =>
568 observeRpcStreamEffect(
569 WS_METHODS.subscribeOrchestrationDomainEvents,
570 Effect.gen(function* () {
571 const snapshot = yield* orchestrationEngine.getReadModel();
572 const fromSequenceExclusive = snapshot.snapshotSequence;
573 const replayEvents: Array<OrchestrationEvent> = yield* Stream.runCollect(
574 orchestrationEngine.readEvents(fromSequenceExclusive),
575 ).pipe(
576 Effect.map((events) => Array.from(events)),
577 Effect.flatMap(enrichOrchestrationEvents),
578 Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
579 );
580 const replayStream = Stream.fromIterable(replayEvents);
581 const liveStream = orchestrationEngine.streamDomainEvents.pipe(
582 Stream.mapEffect(enrichProjectEvent),
583 );
584 const source = Stream.merge(replayStream, liveStream);
585 type SequenceState = {
586 readonly nextSequence: number;
587 readonly pendingBySequence: Map<number, OrchestrationEvent>;
588 };
589 const state = yield* Ref.make<SequenceState>({
590 nextSequence: fromSequenceExclusive + 1,
591 pendingBySequence: new Map<number, OrchestrationEvent>(),
592 });
524593
525 return source.pipe(
526 Stream.mapEffect((event) =>
527 Ref.modify(
528 state,
529 ({
530 nextSequence,
531 pendingBySequence,
532 }): [Array<OrchestrationEvent>, SequenceState] => {
533 if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) {
534 return [[], { nextSequence, pendingBySequence }];
535 }
536
537 const updatedPending = new Map(pendingBySequence);
538 updatedPending.set(event.sequence, event);
539
540 const emit: Array<OrchestrationEvent> = [];
541 let expected = nextSequence;
542 for (;;) {
543 const expectedEvent = updatedPending.get(expected);
544 if (!expectedEvent) {
545 break;
594 return source.pipe(
595 Stream.mapEffect((event) =>
596 Ref.modify(
597 state,
598 ({
599 nextSequence,
600 pendingBySequence,
601 }): [Array<OrchestrationEvent>, SequenceState] => {
602 if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) {
603 return [[], { nextSequence, pendingBySequence }];
546604 }
547 emit.push(expectedEvent);
548 updatedPending.delete(expected);
549 expected += 1;
550 }
551605
552 return [emit, { nextSequence: expected, pendingBySequence: updatedPending }];
553 },
606 const updatedPending = new Map(pendingBySequence);
607 updatedPending.set(event.sequence, event);
608
609 const emit: Array<OrchestrationEvent> = [];
610 let expected = nextSequence;
611 for (;;) {
612 const expectedEvent = updatedPending.get(expected);
613 if (!expectedEvent) {
614 break;
615 }
616 emit.push(expectedEvent);
617 updatedPending.delete(expected);
618 expected += 1;
619 }
620
621 return [emit, { nextSequence: expected, pendingBySequence: updatedPending }];
622 },
623 ),
554624 ),
555 ),
556 Stream.flatMap((events) => Stream.fromIterable(events)),
557 );
625 Stream.flatMap((events) => Stream.fromIterable(events)),
626 );
627 }),
628 { "rpc.aggregate": "orchestration" },
629 ),
630 [WS_METHODS.serverGetConfig]: (_input) =>
631 observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, {
632 "rpc.aggregate": "server",
558633 }),
559 { "rpc.aggregate": "orchestration" },
560 ),
561 [WS_METHODS.serverGetConfig]: (_input) =>
562 observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, {
563 "rpc.aggregate": "server",
564 }),
565 [WS_METHODS.serverRefreshProviders]: (_input) =>
566 observeRpcEffect(
567 WS_METHODS.serverRefreshProviders,
568 providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))),
569 { "rpc.aggregate": "server" },
570 ),
571 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
572 observeRpcEffect(
573 WS_METHODS.serverUpsertKeybinding,
574 Effect.gen(function* () {
575 const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule);
576 return { keybindings: keybindingsConfig, issues: [] };
634 [WS_METHODS.serverRefreshProviders]: (_input) =>
635 observeRpcEffect(
636 WS_METHODS.serverRefreshProviders,
637 providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))),
638 { "rpc.aggregate": "server" },
639 ),
640 [WS_METHODS.serverUpsertKeybinding]: (rule) =>
641 observeRpcEffect(
642 WS_METHODS.serverUpsertKeybinding,
643 Effect.gen(function* () {
644 const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule);
645 return { keybindings: keybindingsConfig, issues: [] };
646 }),
647 { "rpc.aggregate": "server" },
648 ),
649 [WS_METHODS.serverGetSettings]: (_input) =>
650 observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, {
651 "rpc.aggregate": "server",
652 }),
653 [WS_METHODS.serverUpdateSettings]: ({ patch }) =>
654 observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), {
655 "rpc.aggregate": "server",
577656 }),
578 { "rpc.aggregate": "server" },
579 ),
580 [WS_METHODS.serverGetSettings]: (_input) =>
581 observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, {
582 "rpc.aggregate": "server",
583 }),
584 [WS_METHODS.serverUpdateSettings]: ({ patch }) =>
585 observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), {
586 "rpc.aggregate": "server",
587 }),
588 [WS_METHODS.projectsSearchEntries]: (input) =>
589 observeRpcEffect(
590 WS_METHODS.projectsSearchEntries,
591 workspaceEntries.search(input).pipe(
592 Effect.mapError(
593 (cause) =>
594 new ProjectSearchEntriesError({
595 message: `Failed to search workspace entries: ${cause.detail}`,
657 [WS_METHODS.projectsSearchEntries]: (input) =>
658 observeRpcEffect(
659 WS_METHODS.projectsSearchEntries,
660 workspaceEntries.search(input).pipe(
661 Effect.mapError(
662 (cause) =>
663 new ProjectSearchEntriesError({
664 message: `Failed to search workspace entries: ${cause.detail}`,
665 cause,
666 }),
667 ),
668 ),
669 { "rpc.aggregate": "workspace" },
670 ),
671 [WS_METHODS.projectsWriteFile]: (input) =>
672 observeRpcEffect(
673 WS_METHODS.projectsWriteFile,
674 workspaceFileSystem.writeFile(input).pipe(
675 Effect.mapError((cause) => {
676 const message = Schema.is(WorkspacePathOutsideRootError)(cause)
677 ? "Workspace file path must stay within the project root."
678 : "Failed to write workspace file";
679 return new ProjectWriteFileError({
680 message,
596681 cause,
597 }),
682 });
683 }),
598684 ),
685 { "rpc.aggregate": "workspace" },
599686 ),
600 { "rpc.aggregate": "workspace" },
601 ),
602 [WS_METHODS.projectsWriteFile]: (input) =>
603 observeRpcEffect(
604 WS_METHODS.projectsWriteFile,
605 workspaceFileSystem.writeFile(input).pipe(
606 Effect.mapError((cause) => {
607 const message = Schema.is(WorkspacePathOutsideRootError)(cause)
608 ? "Workspace file path must stay within the project root."
609 : "Failed to write workspace file";
610 return new ProjectWriteFileError({
611 message,
612 cause,
613 });
614 }),
687 [WS_METHODS.shellOpenInEditor]: (input) =>
688 observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), {
689 "rpc.aggregate": "workspace",
690 }),
691 [WS_METHODS.subscribeGitStatus]: (input) =>
692 observeRpcStream(
693 WS_METHODS.subscribeGitStatus,
694 gitStatusBroadcaster.streamStatus(input),
695 {
696 "rpc.aggregate": "git",
697 },
615698 ),
616 { "rpc.aggregate": "workspace" },
617 ),
618 [WS_METHODS.shellOpenInEditor]: (input) =>
619 observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), {
620 "rpc.aggregate": "workspace",
621 }),
622 [WS_METHODS.subscribeGitStatus]: (input) =>
623 observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), {
624 "rpc.aggregate": "git",
625 }),
626 [WS_METHODS.gitRefreshStatus]: (input) =>
627 observeRpcEffect(
628 WS_METHODS.gitRefreshStatus,
629 gitStatusBroadcaster.refreshStatus(input.cwd),
630 {
631 "rpc.aggregate": "git",
632 },
633 ),
634 [WS_METHODS.gitPull]: (input) =>
635 observeRpcEffect(
636 WS_METHODS.gitPull,
637 git.pullCurrentBranch(input.cwd).pipe(
638 Effect.matchCauseEffect({
639 onFailure: (cause) => Effect.failCause(cause),
640 onSuccess: (result) =>
641 refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)),
642 }),
699 [WS_METHODS.gitRefreshStatus]: (input) =>
700 observeRpcEffect(
701 WS_METHODS.gitRefreshStatus,
702 gitStatusBroadcaster.refreshStatus(input.cwd),
703 {
704 "rpc.aggregate": "git",
705 },
706 ),
707 [WS_METHODS.gitPull]: (input) =>
708 observeRpcEffect(
709 WS_METHODS.gitPull,
710 git.pullCurrentBranch(input.cwd).pipe(
711 Effect.matchCauseEffect({
712 onFailure: (cause) => Effect.failCause(cause),
713 onSuccess: (result) =>
714 refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)),
715 }),
716 ),
717 { "rpc.aggregate": "git" },
643718 ),
644 { "rpc.aggregate": "git" },
645 ),
646 [WS_METHODS.gitRunStackedAction]: (input) =>
647 observeRpcStream(
648 WS_METHODS.gitRunStackedAction,
649 Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) =>
719 [WS_METHODS.gitRunStackedAction]: (input) =>
720 observeRpcStream(
721 WS_METHODS.gitRunStackedAction,
722 Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) =>
723 gitManager
724 .runStackedAction(input, {
725 actionId: input.actionId,
726 progressReporter: {
727 publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid),
728 },
729 })
730 .pipe(
731 Effect.matchCauseEffect({
732 onFailure: (cause) => Queue.failCause(queue, cause),
733 onSuccess: () =>
734 refreshGitStatus(input.cwd).pipe(
735 Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)),
736 ),
737 }),
738 ),
739 ),
740 { "rpc.aggregate": "git" },
741 ),
742 [WS_METHODS.gitResolvePullRequest]: (input) =>
743 observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), {
744 "rpc.aggregate": "git",
745 }),
746 [WS_METHODS.gitPreparePullRequestThread]: (input) =>
747 observeRpcEffect(
748 WS_METHODS.gitPreparePullRequestThread,
650749 gitManager
651 .runStackedAction(input, {
652 actionId: input.actionId,
653 progressReporter: {
654 publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid),
655 },
656 })
657 .pipe(
658 Effect.matchCauseEffect({
659 onFailure: (cause) => Queue.failCause(queue, cause),
660 onSuccess: () =>
661 refreshGitStatus(input.cwd).pipe(
662 Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)),
663 ),
664 }),
665 ),
750 .preparePullRequestThread(input)
751 .pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
752 { "rpc.aggregate": "git" },
753 ),
754 [WS_METHODS.gitListBranches]: (input) =>
755 observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), {
756 "rpc.aggregate": "git",
757 }),
758 [WS_METHODS.gitCreateWorktree]: (input) =>
759 observeRpcEffect(
760 WS_METHODS.gitCreateWorktree,
761 git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
762 { "rpc.aggregate": "git" },
763 ),
764 [WS_METHODS.gitRemoveWorktree]: (input) =>
765 observeRpcEffect(
766 WS_METHODS.gitRemoveWorktree,
767 git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
768 { "rpc.aggregate": "git" },
666769 ),
667 { "rpc.aggregate": "git" },
668 ),
669 [WS_METHODS.gitResolvePullRequest]: (input) =>
670 observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), {
671 "rpc.aggregate": "git",
672 }),
673 [WS_METHODS.gitPreparePullRequestThread]: (input) =>
674 observeRpcEffect(
675 WS_METHODS.gitPreparePullRequestThread,
676 gitManager
677 .preparePullRequestThread(input)
678 .pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
679 { "rpc.aggregate": "git" },
680 ),
681 [WS_METHODS.gitListBranches]: (input) =>
682 observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), {
683 "rpc.aggregate": "git",
684 }),
685 [WS_METHODS.gitCreateWorktree]: (input) =>
686 observeRpcEffect(
687 WS_METHODS.gitCreateWorktree,
688 git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
689 { "rpc.aggregate": "git" },
690 ),
691 [WS_METHODS.gitRemoveWorktree]: (input) =>
692 observeRpcEffect(
693 WS_METHODS.gitRemoveWorktree,
694 git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
695 { "rpc.aggregate": "git" },
696 ),
697 [WS_METHODS.gitCreateBranch]: (input) =>
698 observeRpcEffect(
699 WS_METHODS.gitCreateBranch,
700 git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
701 { "rpc.aggregate": "git" },
702 ),
703 [WS_METHODS.gitCheckout]: (input) =>
704 observeRpcEffect(
705 WS_METHODS.gitCheckout,
706 Effect.scoped(git.checkoutBranch(input)).pipe(
707 Effect.tap(() => refreshGitStatus(input.cwd)),
770 [WS_METHODS.gitCreateBranch]: (input) =>
771 observeRpcEffect(
772 WS_METHODS.gitCreateBranch,
773 git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
774 { "rpc.aggregate": "git" },
708775 ),
709 { "rpc.aggregate": "git" },
710 ),
711 [WS_METHODS.gitInit]: (input) =>
712 observeRpcEffect(
713 WS_METHODS.gitInit,
714 git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
715 { "rpc.aggregate": "git" },
716 ),
717 [WS_METHODS.terminalOpen]: (input) =>
718 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {
719 "rpc.aggregate": "terminal",
720 }),
721 [WS_METHODS.terminalWrite]: (input) =>
722 observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), {
723 "rpc.aggregate": "terminal",
724 }),
725 [WS_METHODS.terminalResize]: (input) =>
726 observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), {
727 "rpc.aggregate": "terminal",
728 }),
729 [WS_METHODS.terminalClear]: (input) =>
730 observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), {
731 "rpc.aggregate": "terminal",
732 }),
733 [WS_METHODS.terminalRestart]: (input) =>
734 observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), {
735 "rpc.aggregate": "terminal",
736 }),
737 [WS_METHODS.terminalClose]: (input) =>
738 observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), {
739 "rpc.aggregate": "terminal",
740 }),
741 [WS_METHODS.subscribeTerminalEvents]: (_input) =>
742 observeRpcStream(
743 WS_METHODS.subscribeTerminalEvents,
744 Stream.callback<TerminalEvent>((queue) =>
745 Effect.acquireRelease(
746 terminalManager.subscribe((event) => Queue.offer(queue, event)),
747 (unsubscribe) => Effect.sync(unsubscribe),
776 [WS_METHODS.gitCheckout]: (input) =>
777 observeRpcEffect(
778 WS_METHODS.gitCheckout,
779 Effect.scoped(git.checkoutBranch(input)).pipe(
780 Effect.tap(() => refreshGitStatus(input.cwd)),
748781 ),
782 { "rpc.aggregate": "git" },
749783 ),
750 { "rpc.aggregate": "terminal" },
751 ),
752 [WS_METHODS.subscribeServerConfig]: (_input) =>
753 observeRpcStreamEffect(
754 WS_METHODS.subscribeServerConfig,
755 Effect.gen(function* () {
756 const keybindingsUpdates = keybindings.streamChanges.pipe(
757 Stream.map((event) => ({
758 version: 1 as const,
759 type: "keybindingsUpdated" as const,
760 payload: {
761 issues: event.issues,
762 },
763 })),
764 );
765 const providerStatuses = providerRegistry.streamChanges.pipe(
766 Stream.map((providers) => ({
767 version: 1 as const,
768 type: "providerStatuses" as const,
769 payload: { providers },
770 })),
771 );
772 const settingsUpdates = serverSettings.streamChanges.pipe(
773 Stream.map((settings) => ({
774 version: 1 as const,
775 type: "settingsUpdated" as const,
776 payload: { settings },
777 })),
778 );
779
780 return Stream.concat(
781 Stream.make({
782 version: 1 as const,
783 type: "snapshot" as const,
784 config: yield* loadServerConfig,
785 }),
786 Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)),
787 );
784 [WS_METHODS.gitInit]: (input) =>
785 observeRpcEffect(
786 WS_METHODS.gitInit,
787 git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
788 { "rpc.aggregate": "git" },
789 ),
790 [WS_METHODS.terminalOpen]: (input) =>
791 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {
792 "rpc.aggregate": "terminal",
788793 }),
789 { "rpc.aggregate": "server" },
790 ),
791 [WS_METHODS.subscribeServerLifecycle]: (_input) =>
792 observeRpcStreamEffect(
793 WS_METHODS.subscribeServerLifecycle,
794 Effect.gen(function* () {
795 const snapshot = yield* lifecycleEvents.snapshot;
796 const snapshotEvents = Array.from(snapshot.events).toSorted(
797 (left, right) => left.sequence - right.sequence,
798 );
799 const liveEvents = lifecycleEvents.stream.pipe(
800 Stream.filter((event) => event.sequence > snapshot.sequence),
801 );
802 return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents);
794 [WS_METHODS.terminalWrite]: (input) =>
795 observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), {
796 "rpc.aggregate": "terminal",
803797 }),
804 { "rpc.aggregate": "server" },
805 ),
806 });
807 }),
808);
798 [WS_METHODS.terminalResize]: (input) =>
799 observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), {
800 "rpc.aggregate": "terminal",
801 }),
802 [WS_METHODS.terminalClear]: (input) =>
803 observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), {
804 "rpc.aggregate": "terminal",
805 }),
806 [WS_METHODS.terminalRestart]: (input) =>
807 observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), {
808 "rpc.aggregate": "terminal",
809 }),
810 [WS_METHODS.terminalClose]: (input) =>
811 observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), {
812 "rpc.aggregate": "terminal",
813 }),
814 [WS_METHODS.subscribeTerminalEvents]: (_input) =>
815 observeRpcStream(
816 WS_METHODS.subscribeTerminalEvents,
817 Stream.callback<TerminalEvent>((queue) =>
818 Effect.acquireRelease(
819 terminalManager.subscribe((event) => Queue.offer(queue, event)),
820 (unsubscribe) => Effect.sync(unsubscribe),
821 ),
822 ),
823 { "rpc.aggregate": "terminal" },
824 ),
825 [WS_METHODS.subscribeServerConfig]: (_input) =>
826 observeRpcStreamEffect(
827 WS_METHODS.subscribeServerConfig,
828 Effect.gen(function* () {
829 const keybindingsUpdates = keybindings.streamChanges.pipe(
830 Stream.map((event) => ({
831 version: 1 as const,
832 type: "keybindingsUpdated" as const,
833 payload: {
834 issues: event.issues,
835 },
836 })),
837 );
838 const providerStatuses = providerRegistry.streamChanges.pipe(
839 Stream.map((providers) => ({
840 version: 1 as const,
841 type: "providerStatuses" as const,
842 payload: { providers },
843 })),
844 );
845 const settingsUpdates = serverSettings.streamChanges.pipe(
846 Stream.map((settings) => ({
847 version: 1 as const,
848 type: "settingsUpdated" as const,
849 payload: { settings },
850 })),
851 );
852
853 return Stream.concat(
854 Stream.make({
855 version: 1 as const,
856 type: "snapshot" as const,
857 config: yield* loadServerConfig,
858 }),
859 Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)),
860 );
861 }),
862 { "rpc.aggregate": "server" },
863 ),
864 [WS_METHODS.subscribeServerLifecycle]: (_input) =>
865 observeRpcStreamEffect(
866 WS_METHODS.subscribeServerLifecycle,
867 Effect.gen(function* () {
868 const snapshot = yield* lifecycleEvents.snapshot;
869 const snapshotEvents = Array.from(snapshot.events).toSorted(
870 (left, right) => left.sequence - right.sequence,
871 );
872 const liveEvents = lifecycleEvents.stream.pipe(
873 Stream.filter((event) => event.sequence > snapshot.sequence),
874 );
875 return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents);
876 }),
877 { "rpc.aggregate": "server" },
878 ),
879 [WS_METHODS.subscribeAuthAccess]: (_input) =>
880 observeRpcStreamEffect(
881 WS_METHODS.subscribeAuthAccess,
882 Effect.gen(function* () {
883 const initialSnapshot = yield* loadAuthAccessSnapshot();
884 const revisionRef = yield* Ref.make(1);
885 const accessChanges: Stream.Stream<
886 BootstrapCredentialChange | SessionCredentialChange
887 > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges);
888
889 const liveEvents: Stream.Stream<AuthAccessStreamEvent> = accessChanges.pipe(
890 Stream.mapEffect((change) =>
891 Ref.updateAndGet(revisionRef, (revision) => revision + 1).pipe(
892 Effect.map((revision) =>
893 toAuthAccessStreamEvent(change, revision, currentSessionId),
894 ),
895 ),
896 ),
897 );
898
899 return Stream.concat(
900 Stream.make({
901 version: 1 as const,
902 revision: 1,
903 type: "snapshot" as const,
904 payload: initialSnapshot,
905 }),
906 liveEvents,
907 );
908 }),
909 { "rpc.aggregate": "auth" },
910 ),
911 });
912 }),
913 );
809914
810915export const websocketRpcRouteLayer = Layer.unwrap(
811 Effect.gen(function* () {
812 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {
813 spanPrefix: "ws.rpc",
814 spanAttributes: {
815 "rpc.transport": "websocket",
816 "rpc.system": "effect-rpc",
817 },
818 }).pipe(Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson)));
819 return HttpRouter.add(
916 Effect.succeed(
917 HttpRouter.add(
820918 "GET",
821919 "/ws",
822920 Effect.gen(function* () {
823921 const request = yield* HttpServerRequest.HttpServerRequest;
824 const config = yield* ServerConfig;
825 if (config.authToken) {
826 const url = HttpServerRequest.toURL(request);
827 if (Option.isNone(url)) {
828 return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 });
829 }
830 const token = url.value.searchParams.get("token");
831 if (token !== config.authToken) {
832 return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 });
833 }
834 }
835 return yield* rpcWebSocketHttpEffect;
836 }),
837 );
838 }),
922 const serverAuth = yield* ServerAuth;
923 const sessions = yield* SessionCredentialService;
924 const session = yield* serverAuth.authenticateWebSocketUpgrade(request);
925 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {
926 spanPrefix: "ws.rpc",
927 spanAttributes: {
928 "rpc.transport": "websocket",
929 "rpc.system": "effect-rpc",
930 },
931 }).pipe(
932 Effect.provide(
933 makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)),
934 ),
935 );
936 return yield* Effect.acquireUseRelease(
937 sessions.markConnected(session.sessionId),
938 () => rpcWebSocketHttpEffect,
939 () => sessions.markDisconnected(session.sessionId),
940 );
941 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
942 ),
943 ),
839944);
apps/web/index.html+83-3
......@@ -2,9 +2,84 @@
22<html lang="en">
33 <head>
44 <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" />
69 <link rel="icon" href="/favicon.ico" sizes="48x48" />
710 <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>
883 <link rel="preconnect" href="https://fonts.googleapis.com" />
984 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1085 <link
......@@ -12,10 +87,15 @@
1287 rel="stylesheet"
1388 />
1489 <title>T3 Code (Alpha)</title>
15 <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
1690 </head>
1791 <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>
1999 <script type="module" src="/src/main.tsx"></script>
20100 </body>
21101</html>
apps/web/src/authBootstrap.test.ts created+460
......@@ -0,0 +1,460 @@
1import type { DesktopBridge } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
4function 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
14type TestWindow = {
15 location: URL;
16 history: {
17 replaceState: (_data: unknown, _unused: string, url: string) => void;
18 };
19 desktopBridge?: DesktopBridge;
20};
21
22function 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
38function sessionResponse(body: unknown, init?: ResponseInit) {
39 return jsonResponse(body, init);
40}
41
42describe("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 @@
1import type { GitBranch } from "@t3tools/contracts";
1import type { EnvironmentId, GitBranch, ProjectId } from "@t3tools/contracts";
22import { Schema } from "effect";
33export {
44 dedupeRemoteBranchesWithLocalMatches,
55 deriveLocalBranchNameFromRemoteRef,
66} from "@t3tools/shared/git";
77
8export interface EnvironmentOption {
9 environmentId: EnvironmentId;
10 projectId: ProjectId;
11 label: string;
12 isPrimary: boolean;
13}
14
815export const EnvMode = Schema.Literals(["local", "worktree"]);
916export type EnvMode = typeof EnvMode.Type;
1017
apps/web/src/components/BranchToolbar.tsx+37-127
......@@ -1,25 +1,19 @@
11import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
22import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { FolderIcon, GitForkIcon } from "lucide-react";
4import { useCallback, useMemo } from "react";
3import { useMemo } from "react";
54
65import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
7import { readEnvironmentApi } from "../environmentApi";
8import { newCommandId } from "../lib/utils";
96import { useStore } from "../store";
107import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
118import {
12 EnvMode,
13 resolveDraftEnvModeAfterBranchChange,
9 type EnvMode,
10 type EnvironmentOption,
1411 resolveEffectiveEnvMode,
1512} from "./BranchToolbar.logic";
1613import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
17import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
18
19const envModeItems = [
20 { value: "local", label: "Local" },
21 { value: "worktree", label: "New worktree" },
22] as const;
14import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
15import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
16import { Separator } from "./ui/separator";
2317
2418interface BranchToolbarProps {
2519 environmentId: EnvironmentId;
......@@ -29,9 +23,11 @@ interface BranchToolbarProps {
2923 envLocked: boolean;
3024 onCheckoutPullRequestRequest?: (reference: string) => void;
3125 onComposerFocusRequest?: () => void;
26 availableEnvironments?: readonly EnvironmentOption[];
27 onEnvironmentChange?: (environmentId: EnvironmentId) => void;
3228}
3329
34export default function BranchToolbar({
30export function BranchToolbar({
3531 environmentId,
3632 threadId,
3733 draftId,
......@@ -39,6 +35,8 @@ export default function BranchToolbar({
3935 envLocked,
4036 onCheckoutPullRequestRequest,
4137 onComposerFocusRequest,
38 availableEnvironments,
39 onEnvironmentChange,
4240}: BranchToolbarProps) {
4341 const threadRef = useMemo(
4442 () => scopeThreadRef(environmentId, threadId),
......@@ -46,10 +44,7 @@ export default function BranchToolbar({
4644 );
4745 const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]);
4846 const serverThread = useStore(serverThreadSelector);
49 const serverSession = serverThread?.session ?? null;
50 const setThreadBranchAction = useStore((store) => store.setThreadBranch);
5147 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
52 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
5348 const activeProjectRef = serverThread
5449 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
5550 : draftThread
......@@ -60,131 +55,46 @@ export default function BranchToolbar({
6055 [activeProjectRef],
6156 );
6257 const activeProject = useStore(activeProjectSelector);
63 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
64 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
58 const hasActiveThread = serverThread !== undefined || draftThread !== null;
6559 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
66 const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null;
67 const hasServerThread = serverThread !== undefined;
6860 const effectiveEnvMode = resolveEffectiveEnvMode({
6961 activeWorktreePath,
70 hasServerThread,
62 hasServerThread: serverThread !== undefined,
7163 draftThreadEnvMode: draftThread?.envMode,
7264 });
7365
74 const setThreadBranch = useCallback(
75 (branch: string | null, worktreePath: string | null) => {
76 if (!activeThreadId || !activeProject) return;
77 const api = readEnvironmentApi(environmentId);
78 // If the effective cwd is about to change, stop the running session so the
79 // next message creates a new one with the correct cwd.
80 if (serverSession && worktreePath !== activeWorktreePath && api) {
81 void api.orchestration
82 .dispatchCommand({
83 type: "thread.session.stop",
84 commandId: newCommandId(),
85 threadId: activeThreadId,
86 createdAt: new Date().toISOString(),
87 })
88 .catch(() => undefined);
89 }
90 if (api && hasServerThread) {
91 void api.orchestration.dispatchCommand({
92 type: "thread.meta.update",
93 commandId: newCommandId(),
94 threadId: activeThreadId,
95 branch,
96 worktreePath,
97 });
98 }
99 if (hasServerThread) {
100 setThreadBranchAction(activeThreadId, branch, worktreePath);
101 return;
102 }
103 const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({
104 nextWorktreePath: worktreePath,
105 currentWorktreePath: activeWorktreePath,
106 effectiveEnvMode,
107 });
108 setDraftThreadContext(draftId ?? threadRef, {
109 branch,
110 worktreePath,
111 envMode: nextDraftEnvMode,
112 projectRef: scopeProjectRef(environmentId, activeProject.id),
113 });
114 },
115 [
116 activeThreadId,
117 activeProject,
118 serverSession,
119 activeWorktreePath,
120 hasServerThread,
121 setThreadBranchAction,
122 setDraftThreadContext,
123 draftId,
124 threadRef,
125 environmentId,
126 effectiveEnvMode,
127 ],
128 );
66 const showEnvironmentPicker =
67 availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange;
12968
130 if (!activeThreadId || !activeProject) return null;
69 if (!hasActiveThread || !activeProject) return null;
13170
13271 return (
13372 <div className="mx-auto flex w-full max-w-3xl items-center justify-between px-5 pb-3 pt-1">
134 {envLocked || activeWorktreePath ? (
135 <span className="inline-flex items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
136 {activeWorktreePath ? (
137 <>
138 <GitForkIcon className="size-3" />
139 Worktree
140 </>
141 ) : (
142 <>
143 <FolderIcon className="size-3" />
144 Local
145 </>
146 )}
147 </span>
148 ) : (
149 <Select
150 value={effectiveEnvMode}
151 onValueChange={(value) => onEnvModeChange(value as EnvMode)}
152 items={envModeItems}
153 >
154 <SelectTrigger variant="ghost" size="xs" className="font-medium">
155 {effectiveEnvMode === "worktree" ? (
156 <GitForkIcon className="size-3" />
157 ) : (
158 <FolderIcon className="size-3" />
159 )}
160 <SelectValue />
161 </SelectTrigger>
162 <SelectPopup>
163 <SelectItem value="local">
164 <span className="inline-flex items-center gap-1.5">
165 <FolderIcon className="size-3" />
166 Local
167 </span>
168 </SelectItem>
169 <SelectItem value="worktree">
170 <span className="inline-flex items-center gap-1.5">
171 <GitForkIcon className="size-3" />
172 New worktree
173 </span>
174 </SelectItem>
175 </SelectPopup>
176 </Select>
177 )}
73 <div className="flex items-center gap-1">
74 {showEnvironmentPicker && (
75 <>
76 <BranchToolbarEnvironmentSelector
77 envLocked={envLocked}
78 environmentId={environmentId}
79 availableEnvironments={availableEnvironments}
80 onEnvironmentChange={onEnvironmentChange}
81 />
82 <Separator orientation="vertical" className="mx-0.5 h-3.5!" />
83 </>
84 )}
85 <BranchToolbarEnvModeSelector
86 envLocked={envLocked}
87 effectiveEnvMode={effectiveEnvMode}
88 activeWorktreePath={activeWorktreePath}
89 onEnvModeChange={onEnvModeChange}
90 />
91 </div>
17892
17993 <BranchToolbarBranchSelector
18094 environmentId={environmentId}
181 activeProjectCwd={activeProject.cwd}
182 activeThreadBranch={activeThreadBranch}
183 activeWorktreePath={activeWorktreePath}
184 branchCwd={branchCwd}
185 effectiveEnvMode={effectiveEnvMode}
95 threadId={threadId}
96 {...(draftId ? { draftId } : {})}
18697 envLocked={envLocked}
187 onSetThreadBranch={setThreadBranch}
18898 {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
18999 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
190100 />
apps/web/src/components/BranchToolbarBranchSelector.tsx+127-33
......@@ -1,4 +1,5 @@
1import type { EnvironmentId, GitBranch } from "@t3tools/contracts";
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts";
23import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
34import { useVirtualizer } from "@tanstack/react-virtual";
45import { ChevronDownIcon } from "lucide-react";
......@@ -14,15 +15,20 @@ import {
1415 useTransition,
1516} from "react";
1617
18import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
19import { readEnvironmentApi } from "../environmentApi";
1720import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";
1821import { useGitStatus } from "../lib/gitStatusState";
19import { readEnvironmentApi } from "../environmentApi";
22import { newCommandId } from "../lib/utils";
2023import { parsePullRequestReference } from "../pullRequestReference";
24import { useStore } from "../store";
25import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
2126import {
2227 deriveLocalBranchNameFromRemoteRef,
23 EnvMode,
2428 resolveBranchSelectionTarget,
2529 resolveBranchToolbarValue,
30 resolveDraftEnvModeAfterBranchChange,
31 resolveEffectiveEnvMode,
2632 shouldIncludeBranchPickerItem,
2733} from "./BranchToolbar.logic";
2834import { Button } from "./ui/button";
......@@ -40,13 +46,9 @@ import { toastManager } from "./ui/toast";
4046
4147interface BranchToolbarBranchSelectorProps {
4248 environmentId: EnvironmentId;
43 activeProjectCwd: string;
44 activeThreadBranch: string | null;
45 activeWorktreePath: string | null;
46 branchCwd: string | null;
47 effectiveEnvMode: EnvMode;
49 threadId: ThreadId;
50 draftId?: DraftId;
4851 envLocked: boolean;
49 onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void;
5052 onCheckoutPullRequestRequest?: (reference: string) => void;
5153 onComposerFocusRequest?: () => void;
5254}
......@@ -57,7 +59,7 @@ function toBranchActionErrorMessage(error: unknown): string {
5759
5860function getBranchTriggerLabel(input: {
5961 activeWorktreePath: string | null;
60 effectiveEnvMode: EnvMode;
62 effectiveEnvMode: "local" | "worktree";
6163 resolvedActiveBranch: string | null;
6264}): string {
6365 const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input;
......@@ -72,16 +74,109 @@ function getBranchTriggerLabel(input: {
7274
7375export function BranchToolbarBranchSelector({
7476 environmentId,
75 activeProjectCwd,
76 activeThreadBranch,
77 activeWorktreePath,
78 branchCwd,
79 effectiveEnvMode,
77 threadId,
78 draftId,
8079 envLocked,
81 onSetThreadBranch,
8280 onCheckoutPullRequestRequest,
8381 onComposerFocusRequest,
8482}: 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 // ---------------------------------------------------------------------------
85180 const queryClient = useQueryClient();
86181 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
87182 const [branchQuery, setBranchQuery] = useState("");
......@@ -183,6 +278,9 @@ export function BranchToolbarBranchSelector({
183278 ? `Showing ${branches.length} of ${totalBranchCount} branches`
184279 : null;
185280
281 // ---------------------------------------------------------------------------
282 // Branch actions
283 // ---------------------------------------------------------------------------
186284 const runBranchAction = (action: () => Promise<void>) => {
187285 startBranchActionTransition(async () => {
188286 await action().catch(() => undefined);
......@@ -194,11 +292,10 @@ export function BranchToolbarBranchSelector({
194292
195293 const selectBranch = (branch: GitBranch) => {
196294 const api = readEnvironmentApi(environmentId);
197 if (!api || !branchCwd || isBranchActionPending) return;
295 if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return;
198296
199 // In new-worktree mode, selecting a branch sets the base branch.
200297 if (isSelectingWorktreeBase) {
201 onSetThreadBranch(branch.name, null);
298 setThreadBranch(branch.name, null);
202299 setIsBranchMenuOpen(false);
203300 onComposerFocusRequest?.();
204301 return;
......@@ -210,9 +307,8 @@ export function BranchToolbarBranchSelector({
210307 branch,
211308 });
212309
213 // If the branch already lives in a worktree, point the thread there.
214310 if (selectionTarget.reuseExistingWorktree) {
215 onSetThreadBranch(branch.name, selectionTarget.nextWorktreePath);
311 setThreadBranch(branch.name, selectionTarget.nextWorktreePath);
216312 setIsBranchMenuOpen(false);
217313 onComposerFocusRequest?.();
218314 return;
......@@ -237,7 +333,7 @@ export function BranchToolbarBranchSelector({
237333 ? (checkoutResult.branch ?? selectedBranchName)
238334 : selectedBranchName;
239335 setOptimisticBranch(nextBranchName);
240 onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
336 setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
241337 } catch (error) {
242338 setOptimisticBranch(previousBranch);
243339 toastManager.add({
......@@ -267,7 +363,7 @@ export function BranchToolbarBranchSelector({
267363 checkout: true,
268364 });
269365 setOptimisticBranch(createBranchResult.branch);
270 onSetThreadBranch(createBranchResult.branch, activeWorktreePath);
366 setThreadBranch(createBranchResult.branch, activeWorktreePath);
271367 } catch (error) {
272368 setOptimisticBranch(previousBranch);
273369 toastManager.add({
......@@ -288,15 +384,12 @@ export function BranchToolbarBranchSelector({
288384 ) {
289385 return;
290386 }
291 onSetThreadBranch(currentGitBranch, null);
292 }, [
293 activeThreadBranch,
294 activeWorktreePath,
295 currentGitBranch,
296 effectiveEnvMode,
297 onSetThreadBranch,
298 ]);
387 setThreadBranch(currentGitBranch, null);
388 }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]);
299389
390 // ---------------------------------------------------------------------------
391 // Combobox / virtualizer plumbing
392 // ---------------------------------------------------------------------------
300393 const handleOpenChange = useCallback(
301394 (open: boolean) => {
302395 setIsBranchMenuOpen(open);
......@@ -437,7 +530,7 @@ export function BranchToolbarBranchSelector({
437530 style={style}
438531 onClick={() => createBranch(trimmedBranchQuery)}
439532 >
440 <span className="truncate">Create new branch "{trimmedBranchQuery}"</span>
533 <span className="truncate">Create new branch &quot;{trimmedBranchQuery}&quot;</span>
441534 </ComboboxItem>
442535 );
443536 }
......@@ -445,7 +538,8 @@ export function BranchToolbarBranchSelector({
445538 const branch = branchByName.get(itemValue);
446539 if (!branch) return null;
447540
448 const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProjectCwd;
541 const hasSecondaryWorktree =
542 branch.worktreePath && activeProjectCwd && branch.worktreePath !== activeProjectCwd;
449543 const badge = branch.current
450544 ? "current"
451545 : hasSecondaryWorktree
apps/web/src/components/BranchToolbarEnvModeSelector.tsx created+73
......@@ -0,0 +1,73 @@
1import { FolderIcon, GitForkIcon } from "lucide-react";
2import { memo } from "react";
3
4import type { EnvMode } from "./BranchToolbar.logic";
5import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
6
7const envModeItems = [
8 { value: "local", label: "Local" },
9 { value: "worktree", label: "New worktree" },
10] as const;
11
12interface BranchToolbarEnvModeSelectorProps {
13 envLocked: boolean;
14 effectiveEnvMode: EnvMode;
15 activeWorktreePath: string | null;
16 onEnvModeChange: (mode: EnvMode) => void;
17}
18
19export 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 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { CloudIcon, FolderIcon, ServerIcon } from "lucide-react";
3import { memo, useMemo } from "react";
4
5import type { EnvironmentOption } from "./BranchToolbar.logic";
6import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
7
8interface BranchToolbarEnvironmentSelectorProps {
9 envLocked: boolean;
10 environmentId: EnvironmentId;
11 availableEnvironments: readonly EnvironmentOption[];
12 onEnvironmentChange: (environmentId: EnvironmentId) => void;
13}
14
15export 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";
4444import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store";
4545import { useTerminalStateStore } from "../terminalStateStore";
4646import { useUiStateStore } from "../uiStateStore";
47import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";
4748import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness";
4849import { estimateTimelineMessageHeight } from "./timelineHeight";
4950import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
......@@ -148,6 +149,12 @@ function createBaseServerConfig(): ServerConfig {
148149 serverVersion: "0.0.0-test",
149150 capabilities: { repositoryIdentity: true },
150151 },
152 auth: {
153 policy: "loopback-browser",
154 bootstrapMethods: ["one-time-token"],
155 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
156 sessionCookieName: "t3_session",
157 },
151158 cwd: "/repo/project",
152159 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
153160 keybindings: [],
......@@ -821,6 +828,7 @@ const worker = setupWorker(
821828 void rpcHarness.onMessage(rawData);
822829 });
823830 }),
831 ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth),
824832 http.get("*/attachments/:attachmentId", () =>
825833 HttpResponse.text(ATTACHMENT_SVG, {
826834 headers: {
......@@ -2629,10 +2637,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
26292637 "Promoted drafts should canonicalize to the server thread route.",
26302638 );
26312639
2632 // The empty thread view and composer should still be visible.
2633 await expect
2634 .element(page.getByText("Send a message to start the conversation."))
2635 .toBeInTheDocument();
2640 // The composer should remain usable after canonicalization, regardless of
2641 // whether the promoted thread is still visibly empty or has already
2642 // entered the running state.
26362643 await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument();
26372644 } finally {
26382645 await mounted.cleanup();
......@@ -2992,6 +2999,17 @@ describe("ChatView timeline estimator parity (full app)", () => {
29922999 const promotedThreadId = draftThreadIdFor(promotedDraftId);
29933000
29943001 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 );
29953013
29963014 const freshThreadPath = await triggerChatNewShortcutUntilPath(
29973015 mounted.router,
apps/web/src/components/ChatView.tsx+224-53
......@@ -38,6 +38,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
3838import { useShallow } from "zustand/react/shallow";
3939import { useGitStatus } from "~/lib/gitStatusState";
4040import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
41import { usePrimaryEnvironmentId } from "../environments/primary";
4142import { readEnvironmentApi } from "../environmentApi";
4243import { isElectron } from "../env";
4344import { readLocalApi } from "../localApi";
......@@ -75,7 +76,11 @@ import {
7576 togglePendingUserInputOptionSelection,
7677 type PendingUserInputDraftAnswer,
7778} from "../pendingUserInput";
78import { selectThreadsAcrossEnvironments, useStore } from "../store";
79import {
80 selectProjectsAcrossEnvironments,
81 selectThreadsAcrossEnvironments,
82 useStore,
83} from "../store";
7984import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
8085import { useUiStateStore } from "../uiStateStore";
8186import {
......@@ -97,7 +102,7 @@ import {
97102import { basenameOfPath } from "../vscode-icons";
98103import { useTheme } from "../hooks/useTheme";
99104import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
100import BranchToolbar from "./BranchToolbar";
105import { BranchToolbar } from "./BranchToolbar";
101106import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
102107import PlanSidebar from "./PlanSidebar";
103108import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
......@@ -124,7 +129,6 @@ import {
124129 nextProjectScriptId,
125130 projectScriptIdFromCommand,
126131} from "~/projectScripts";
127import { SidebarTrigger } from "./ui/sidebar";
128132import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";
129133import {
130134 getProviderModelCapabilities,
......@@ -135,6 +139,10 @@ import { useSettings } from "../hooks/useSettings";
135139import { resolveAppModelSelection } from "../modelSelection";
136140import { isTerminalFocused } from "../lib/terminalFocus";
137141import { deriveLogicalProjectKey } from "../logicalProject";
142import {
143 useSavedEnvironmentRegistryStore,
144 useSavedEnvironmentRuntimeStore,
145} from "../environments/runtime";
138146import { buildDraftThreadRouteParams } from "../threadRoutes";
139147import {
140148 type ComposerImageAttachment,
......@@ -167,6 +175,7 @@ import { MessagesTimeline } from "./chat/MessagesTimeline";
167175import { ChatHeader } from "./chat/ChatHeader";
168176import { ContextWindowMeter } from "./chat/ContextWindowMeter";
169177import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
178import { NoActiveThreadState } from "./NoActiveThreadState";
170179import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
171180import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
172181import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
......@@ -212,7 +221,6 @@ import {
212221} from "~/rpc/serverState";
213222import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
214223
215const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
216224const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
217225const IMAGE_ONLY_BOOTSTRAP_PROMPT =
218226 "[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) {
813821 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
814822 const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);
815823 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
816 const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({});
824 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
817825 const sendInFlightRef = useRef(false);
818826 const dragDepthRef = useRef(0);
819827 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
......@@ -998,6 +1006,54 @@ export default function ChatView(props: ChatViewProps) {
9981006 useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]),
9991007 );
10001008
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
10011057 const openPullRequestDialog = useCallback(
10021058 (reference?: string) => {
10031059 if (!canCheckoutPullRequestIntoThread) {
......@@ -1127,7 +1183,17 @@ export default function ChatView(props: ChatViewProps) {
11271183 const lockedProvider: ProviderKind | null = hasThreadStarted
11281184 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
11291185 : 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);
11311197 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
11321198 const unlockedSelectedProvider = resolveSelectableProvider(
11331199 providerStatuses,
......@@ -1338,11 +1404,28 @@ export default function ChatView(props: ChatViewProps) {
13381404 useEffect(() => {
13391405 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
13401406 }, [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 );
13411427 const clearAttachmentPreviewHandoffs = useCallback(() => {
1342 for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) {
1343 window.clearTimeout(timeoutId);
1344 }
1345 attachmentPreviewHandoffTimeoutByMessageIdRef.current = {};
1428 attachmentPreviewPromotionInFlightByMessageIdRef.current = {};
13461429 for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) {
13471430 for (const previewUrl of previewUrls) {
13481431 revokeBlobPreviewUrl(previewUrl);
......@@ -1376,29 +1459,89 @@ export default function ChatView(props: ChatViewProps) {
13761459 attachmentPreviewHandoffByMessageIdRef.current = next;
13771460 return next;
13781461 });
1379
1380 const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
1381 if (typeof existingTimeout === "number") {
1382 window.clearTimeout(existingTimeout);
1462 }, []);
1463 const serverMessages = activeThread?.messages;
1464 useEffect(() => {
1465 if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) {
1466 return;
13831467 }
1384 attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => {
1385 const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId];
1386 if (currentPreviewUrls) {
1387 for (const previewUrl of currentPreviewUrls) {
1388 revokeBlobPreviewUrl(previewUrl);
1389 }
1468
1469 const cleanups: Array<() => void> = [];
1470
1471 for (const [messageId, handoffPreviewUrls] of Object.entries(
1472 attachmentPreviewHandoffByMessageId,
1473 )) {
1474 if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) {
1475 continue;
13901476 }
1391 setAttachmentPreviewHandoffByMessageId((existing) => {
1392 if (!(messageId in existing)) return existing;
1393 const next = { ...existing };
1394 delete next[messageId];
1395 attachmentPreviewHandoffByMessageIdRef.current = next;
1396 return next;
1477
1478 const serverMessage = serverMessages.find(
1479 (message) => message.id === messageId && message.role === "user",
1480 );
1481 if (!serverMessage?.attachments || serverMessage.attachments.length === 0) {
1482 continue;
1483 }
1484
1485 const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) =>
1486 attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [],
1487 );
1488 if (
1489 serverPreviewUrls.length === 0 ||
1490 serverPreviewUrls.length !== handoffPreviewUrls.length ||
1491 serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:"))
1492 ) {
1493 continue;
1494 }
1495
1496 attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true;
1497
1498 let cancelled = false;
1499 const imageInstances: HTMLImageElement[] = [];
1500
1501 const preloadServerPreviews = Promise.all(
1502 serverPreviewUrls.map(
1503 (previewUrl) =>
1504 new Promise<void>((resolve, reject) => {
1505 const image = new Image();
1506 imageInstances.push(image);
1507 const handleLoad = () => resolve();
1508 const handleError = () =>
1509 reject(new Error(`Failed to load server preview for ${messageId}.`));
1510 image.addEventListener("load", handleLoad, { once: true });
1511 image.addEventListener("error", handleError, { once: true });
1512 image.src = previewUrl;
1513 }),
1514 ),
1515 );
1516
1517 void preloadServerPreviews
1518 .then(() => {
1519 if (cancelled) {
1520 return;
1521 }
1522 clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls);
1523 })
1524 .catch(() => {
1525 if (!cancelled) {
1526 delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId];
1527 }
1528 });
1529
1530 cleanups.push(() => {
1531 cancelled = true;
1532 delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId];
1533 for (const image of imageInstances) {
1534 image.src = "";
1535 }
13971536 });
1398 delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId];
1399 }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS);
1400 }, []);
1401 const serverMessages = activeThread?.messages;
1537 }
1538
1539 return () => {
1540 for (const cleanup of cleanups) {
1541 cleanup();
1542 }
1543 };
1544 }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]);
14021545 const timelineMessages = useMemo(() => {
14031546 const messages = serverMessages ?? [];
14041547 const serverMessagesWithPreviewHandoff =
......@@ -1537,7 +1680,9 @@ export default function ChatView(props: ChatViewProps) {
15371680 const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd });
15381681 const keybindings = useServerKeybindings();
15391682 const availableEditors = useServerAvailableEditors();
1540 const modelOptionsByProvider = useMemo(
1683 const modelOptionsByProvider = useMemo<
1684 Record<ProviderKind, ReadonlyArray<ServerProvider["models"][number]>>
1685 >(
15411686 () => ({
15421687 codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],
15431688 claudeAgent:
......@@ -1730,6 +1875,47 @@ export default function ChatView(props: ChatViewProps) {
17301875 (activeThread.messages.length > 0 ||
17311876 (activeThread.session !== null && activeThread.session.status !== "closed")),
17321877 );
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
17331919 const activeTerminalGroup =
17341920 terminalState.terminalGroups.find(
17351921 (group) => group.id === terminalState.activeTerminalGroupId,
......@@ -4113,28 +4299,7 @@ export default function ChatView(props: ChatViewProps) {
41134299
41144300 // Empty state: no active thread
41154301 if (!activeThread) {
4116 return (
4117 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">
4118 {!isElectron && (
4119 <header className="border-b border-border px-3 py-2 md:hidden">
4120 <div className="flex items-center gap-2">
4121 <SidebarTrigger className="size-7 shrink-0" />
4122 <span className="text-sm font-medium text-foreground">Threads</span>
4123 </div>
4124 </header>
4125 )}
4126 {isElectron && (
4127 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
4128 <span className="text-xs text-muted-foreground/50">No active thread</span>
4129 </div>
4130 )}
4131 <div className="flex flex-1 items-center justify-center">
4132 <div className="text-center">
4133 <p className="text-sm">Select a thread or create a new one to get started.</p>
4134 </div>
4135 </div>
4136 </div>
4137 );
4302 return <NoActiveThreadState />;
41384303 }
41394304
41404305 return (
......@@ -4636,6 +4801,12 @@ export default function ChatView(props: ChatViewProps) {
46364801 {...(canCheckoutPullRequestIntoThread
46374802 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
46384803 : {})}
4804 {...(hasMultipleEnvironments
4805 ? {
4806 availableEnvironments: logicalProjectEnvironments,
4807 onEnvironmentChange,
4808 }
4809 : {})}
46394810 />
46404811 )}
46414812 {pullRequestDialogState ? (
apps/web/src/components/GitActionsControl.browser.tsx+18-1
......@@ -44,8 +44,12 @@ const {
4444 toastUpdateSpy: vi.fn(),
4545}));
4646
47vi.mock("@tanstack/react-query", () => {
47vi.mock("@tanstack/react-query", async () => {
48 const actual =
49 await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
50
4851 return {
52 ...actual,
4953 useIsMutating: vi.fn(() => 0),
5054 useMutation: vi.fn((options: { __kind?: string }) => {
5155 if (options.__kind === "run-stacked-action") {
......@@ -133,6 +137,19 @@ vi.mock("~/store", () => ({
133137 }
134138 return environmentState;
135139 },
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: () => [],
136153 useStore: (selector: (state: unknown) => unknown) =>
137154 selector({
138155 setThreadBranch: setThreadBranchSpy,
apps/web/src/components/KeybindingsToast.browser.tsx+44
......@@ -22,8 +22,10 @@ import { useComposerDraftStore } from "../composerDraftStore";
2222import { __resetLocalApiForTests } from "../localApi";
2323import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
2424import { getServerConfig } from "../rpc/serverState";
25import { getWsConnectionStatus } from "../rpc/wsConnectionState";
2526import { getRouter } from "../router";
2627import { useStore } from "../store";
28import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers";
2729import { BrowserWsRpcHarness } from "../../test/wsRpcHarness";
2830
2931vi.mock("../lib/gitStatusState", () => ({
......@@ -58,6 +60,12 @@ function createBaseServerConfig(): ServerConfig {
5860 serverVersion: "0.0.0-test",
5961 capabilities: { repositoryIdentity: true },
6062 },
63 auth: {
64 policy: "loopback-browser",
65 bootstrapMethods: ["one-time-token"],
66 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
67 sessionCookieName: "t3_session",
68 },
6169 cwd: "/repo/project",
6270 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
6371 keybindings: [],
......@@ -210,6 +218,7 @@ const worker = setupWorker(
210218 void rpcHarness.onMessage(rawData);
211219 });
212220 }),
221 ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth),
213222 http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })),
214223 http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
215224);
......@@ -250,6 +259,22 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
250259 );
251260}
252261
262async 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
269async 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
253278async function waitForToast(title: string, count = 1): Promise<void> {
254279 await vi.waitFor(
255280 () => {
......@@ -269,6 +294,20 @@ async function waitForNoToast(title: string): Promise<void> {
269294 );
270295}
271296
297async function waitForNoToasts(): Promise<void> {
298 await vi.waitFor(
299 () => {
300 expect(queryToastTitles()).toHaveLength(0);
301 },
302 { timeout: 8_000, interval: 16 },
303 );
304}
305
306async function warmServerConfigUpdateStream(): Promise<void> {
307 sendServerConfigUpdatedPush([]);
308 await new Promise((resolve) => setTimeout(resolve, 50));
309}
310
272311async function waitForInitialWsSubscriptions(): Promise<void> {
273312 await vi.waitFor(
274313 () => {
......@@ -313,8 +352,11 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {
313352 { container: host },
314353 );
315354 await waitForComposerEditor();
355 await waitForToastViewport();
316356 await waitForInitialWsSubscriptions();
357 await waitForWsConnection();
317358 await waitForServerConfigSnapshot();
359 await waitForNoToasts();
318360
319361 return {
320362 cleanup: async () => {
......@@ -387,6 +429,8 @@ describe("Keybindings update toast", () => {
387429 const mounted = await mountApp();
388430
389431 try {
432 await warmServerConfigUpdateStream();
433
390434 sendServerConfigUpdatedPush([]);
391435 await waitForToast("Keybindings updated", 1);
392436
apps/web/src/components/NoActiveThreadState.tsx created+41
......@@ -0,0 +1,41 @@
1import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
2import { SidebarInset, SidebarTrigger } from "./ui/sidebar";
3import { isElectron } from "../env";
4import { cn } from "~/lib/utils";
5
6export 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 @@
1import type { EnvironmentId } from "@t3tools/contracts";
12import { FolderIcon } from "lucide-react";
23import { useState } from "react";
3import { resolveServerUrl } from "~/lib/utils";
4import { resolveEnvironmentHttpUrl } from "../environments/runtime";
45
56const loadedProjectFaviconSrcs = new Set<string>();
67
7export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) {
8 const src = resolveServerUrl({
9 protocol: "http",
8export function ProjectFavicon(input: {
9 environmentId: EnvironmentId;
10 cwd: string;
11 className?: string;
12}) {
13 const src = resolveEnvironmentHttpUrl({
14 environmentId: input.environmentId,
1015 pathname: "/api/project-favicon",
11 searchParams: { cwd },
16 searchParams: { cwd: input.cwd },
1217 });
1318 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
1419 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
......@@ -17,12 +22,14 @@ export function ProjectFavicon({ cwd, className }: { cwd: string; className?: st
1722 return (
1823 <>
1924 {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 />
2128 ) : null}
2229 <img
2330 src={src}
2431 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 ?? ""}`}
2633 onLoad={() => {
2734 loadedProjectFaviconSrcs.add(src);
2835 setStatus("loaded");
apps/web/src/components/Sidebar.tsx+279-87
......@@ -2,6 +2,7 @@ import {
22 ArchiveIcon,
33 ArrowUpDownIcon,
44 ChevronRightIcon,
5 CloudIcon,
56 FolderIcon,
67 GitPullRequestIcon,
78 PlusIcon,
......@@ -34,6 +35,7 @@ import {
3435 type DesktopUpdateState,
3536 type EnvironmentId,
3637 ProjectId,
38 type ScopedProjectRef,
3739 type ScopedThreadRef,
3840 type ThreadEnvMode,
3941 ThreadId,
......@@ -50,13 +52,16 @@ import {
5052 type SidebarProjectSortOrder,
5153 type SidebarThreadSortOrder,
5254} from "@t3tools/contracts/settings";
55import { usePrimaryEnvironmentId } from "../environments/primary";
5356import { isElectron } from "../env";
5457import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
5558import { isTerminalFocused } from "../lib/terminalFocus";
5659import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
5760import {
61 selectProjectByRef,
5862 selectProjectsAcrossEnvironments,
5963 selectSidebarThreadsForProjectRef,
64 selectSidebarThreadsForProjectRefs,
6065 selectSidebarThreadsAcrossEnvironments,
6166 selectThreadByRef,
6267 useStore,
......@@ -103,7 +108,6 @@ import {
103108 SidebarFooter,
104109 SidebarGroup,
105110 SidebarHeader,
106 SidebarMenuAction,
107111 SidebarMenu,
108112 SidebarMenuButton,
109113 SidebarMenuItem,
......@@ -135,6 +139,11 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
135139import { readEnvironmentApi } from "../environmentApi";
136140import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
137141import { useServerKeybindings } from "../rpc/serverState";
142import { deriveLogicalProjectKey } from "../logicalProject";
143import {
144 useSavedEnvironmentRegistryStore,
145 useSavedEnvironmentRuntimeStore,
146} from "../environments/runtime";
138147import type { Project, SidebarThreadSummary } from "../types";
139148const THREAD_PREVIEW_LIMIT = 6;
140149const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
......@@ -200,8 +209,14 @@ function buildThreadJumpLabelMap(input: {
200209 return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS;
201210}
202211
212type EnvironmentPresence = "local-only" | "remote-only" | "mixed";
213
203214type SidebarProjectSnapshot = Project & {
204215 projectKey: string;
216 environmentPresence: EnvironmentPresence;
217 memberProjectRefs: readonly ScopedProjectRef[];
218 /** Labels for remote environments this project lives in. */
219 remoteEnvironmentLabels: readonly string[];
205220};
206221interface TerminalStatusIndicator {
207222 label: "Terminal process running";
......@@ -381,7 +396,30 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
381396 (state) =>
382397 selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds,
383398 );
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;
385423 const gitStatus = useGitStatus({
386424 environmentId: thread.environmentId,
387425 cwd: thread.branch != null ? gitCwd : null,
......@@ -673,24 +711,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
673711 )
674712 ) : null}
675713 <span className={threadMetaClassName}>
676 {jumpLabel ? (
677 <span
678 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
679 title={jumpLabel}
680 >
681 {jumpLabel}
682 </span>
683 ) : (
684 <span
685 className={`text-[10px] ${
686 isHighlighted
687 ? "text-foreground/72 dark:text-foreground/82"
688 : "text-muted-foreground/40"
689 }`}
690 >
691 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
692 </span>
693 )}
714 <span className="inline-flex items-center gap-1">
715 {isRemoteThread && (
716 <Tooltip>
717 <TooltipTrigger
718 render={
719 <span
720 aria-label={threadEnvironmentLabel ?? "Remote"}
721 className="inline-flex items-center justify-center"
722 />
723 }
724 >
725 <CloudIcon className="size-3 text-muted-foreground/40" />
726 </TooltipTrigger>
727 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
728 </Tooltip>
729 )}
730 {jumpLabel ? (
731 <span
732 className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm"
733 title={jumpLabel}
734 >
735 {jumpLabel}
736 </span>
737 ) : (
738 <span
739 className={`text-[10px] ${
740 isHighlighted
741 ? "text-foreground/72 dark:text-foreground/82"
742 : "text-muted-foreground/40"
743 }`}
744 >
745 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}
746 </span>
747 )}
748 </span>
694749 </span>
695750 </div>
696751 </div>
......@@ -998,7 +1053,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
9981053 const sidebarThreads = useStore(
9991054 useShallow(
10001055 useMemo(
1001 () => (state) =>
1056 () => (state: import("../store").AppState) =>
10021057 selectSidebarThreadsForProjectRef(
10031058 state,
10041059 scopeProjectRef(project.environmentId, project.id),
......@@ -1007,25 +1062,45 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
10071062 ),
10081063 ),
10091064 );
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 );
10101091 const sidebarThreadByKey = useMemo(
10111092 () =>
10121093 new Map(
1013 sidebarThreads.map(
1094 allSidebarThreads.map(
10141095 (thread) =>
10151096 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
10161097 ),
10171098 ),
1018 [sidebarThreads],
1019 );
1020 const projectThreads = useMemo(
1021 () =>
1022 sidebarThreads.filter(
1023 (thread) =>
1024 scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)) ===
1025 project.projectKey,
1026 ),
1027 [project.projectKey, sidebarThreads],
1099 [allSidebarThreads],
10281100 );
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;
10291104 const projectExpanded = useUiStateStore(
10301105 (state) => state.projectExpandedById[project.projectKey] ?? true,
10311106 );
......@@ -1639,28 +1714,49 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
16391714 }`}
16401715 />
16411716 )}
1642 <ProjectFavicon cwd={project.cwd} />
1717 <ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />
16431718 <span className="flex-1 truncate text-xs font-medium text-foreground/90">
16441719 {project.name}
16451720 </span>
16461721 </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 )}
16471746 <Tooltip>
16481747 <TooltipTrigger
16491748 render={
1650 <SidebarMenuAction
1651 render={
1652 <button
1653 type="button"
1654 aria-label={`Create new thread in ${project.name}`}
1655 data-testid="new-thread-button"
1656 />
1657 }
1658 showOnHover
1659 className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground"
1660 onClick={handleCreateThreadClick}
1661 >
1662 <SquarePenIcon className="size-3.5" />
1663 </SidebarMenuAction>
1749 <div className="pointer-events-none absolute top-1 right-1.5 opacity-0 transition-opacity duration-150 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100">
1750 <button
1751 type="button"
1752 aria-label={`Create new thread in ${project.name}`}
1753 data-testid="new-thread-button"
1754 className="inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground/70 hover:bg-secondary hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
1755 onClick={handleCreateThreadClick}
1756 >
1757 <SquarePenIcon className="size-3.5" />
1758 </button>
1759 </div>
16641760 }
16651761 />
16661762 <TooltipPopup side="top">
......@@ -2238,7 +2334,9 @@ export default function Sidebar() {
22382334 const navigate = useNavigate();
22392335 const pathname = useLocation({ select: (loc) => loc.pathname });
22402336 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);
22422340 const { updateSettings } = useUpdateSettings();
22432341 const { handleNewThread } = useHandleNewThread();
22442342 const { archiveThread, deleteThread } = useThreadActions();
......@@ -2269,6 +2367,9 @@ export default function Sidebar() {
22692367 const platform = navigator.platform;
22702368 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
22712369 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
2370 const primaryEnvironmentId = usePrimaryEnvironmentId();
2371 const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId);
2372 const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId);
22722373 const orderedProjects = useMemo(() => {
22732374 return orderItemsByPreferredIds({
22742375 items: projects,
......@@ -2276,14 +2377,89 @@ export default function Sidebar() {
22762377 getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
22772378 });
22782379 }, [projectOrder, projects]);
2279 const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(
2280 () =>
2281 orderedProjects.map((project) => ({
2282 ...project,
2283 projectKey: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
2284 })),
2285 [orderedProjects],
2286 );
2380
2381 // Build a mapping from physical project key → logical project key for
2382 // cross-environment grouping. Projects that share a repositoryIdentity
2383 // canonicalKey are treated as one logical project in the sidebar.
2384 const physicalToLogicalKey = useMemo(() => {
2385 const mapping = new Map<string, string>();
2386 for (const project of orderedProjects) {
2387 const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
2388 mapping.set(physicalKey, deriveLogicalProjectKey(project));
2389 }
2390 return mapping;
2391 }, [orderedProjects]);
2392
2393 const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(() => {
2394 // Group projects by logical key while preserving insertion order from
2395 // orderedProjects.
2396 const groupedMembers = new Map<string, Project[]>();
2397 for (const project of orderedProjects) {
2398 const logicalKey = deriveLogicalProjectKey(project);
2399 const existing = groupedMembers.get(logicalKey);
2400 if (existing) {
2401 existing.push(project);
2402 } else {
2403 groupedMembers.set(logicalKey, [project]);
2404 }
2405 }
2406
2407 const result: SidebarProjectSnapshot[] = [];
2408 const seen = new Set<string>();
2409 for (const project of orderedProjects) {
2410 const logicalKey = deriveLogicalProjectKey(project);
2411 if (seen.has(logicalKey)) continue;
2412 seen.add(logicalKey);
2413
2414 const members = groupedMembers.get(logicalKey)!;
2415 // Prefer the primary environment's project as the representative.
2416 const representative: Project | undefined =
2417 (primaryEnvironmentId
2418 ? members.find((p) => p.environmentId === primaryEnvironmentId)
2419 : undefined) ?? members[0];
2420 if (!representative) continue;
2421 const hasLocal =
2422 primaryEnvironmentId !== null &&
2423 members.some((p) => p.environmentId === primaryEnvironmentId);
2424 const hasRemote =
2425 primaryEnvironmentId !== null
2426 ? members.some((p) => p.environmentId !== primaryEnvironmentId)
2427 : false;
2428
2429 const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id));
2430 const remoteLabels = members
2431 .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId)
2432 .map((p) => {
2433 const rt = savedEnvironmentRuntimeById[p.environmentId];
2434 const saved = savedEnvironmentRegistry[p.environmentId];
2435 return rt?.descriptor?.label ?? saved?.label ?? p.environmentId;
2436 });
2437 const snapshot: SidebarProjectSnapshot = {
2438 id: representative.id,
2439 environmentId: representative.environmentId,
2440 name: representative.name,
2441 cwd: representative.cwd,
2442 repositoryIdentity: representative.repositoryIdentity ?? null,
2443 defaultModelSelection: representative.defaultModelSelection,
2444 createdAt: representative.createdAt,
2445 updatedAt: representative.updatedAt,
2446 scripts: representative.scripts,
2447 projectKey: logicalKey,
2448 environmentPresence:
2449 hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",
2450 memberProjectRefs: refs,
2451 remoteEnvironmentLabels: remoteLabels,
2452 };
2453 result.push(snapshot);
2454 }
2455 return result;
2456 }, [
2457 orderedProjects,
2458 primaryEnvironmentId,
2459 savedEnvironmentRegistry,
2460 savedEnvironmentRuntimeById,
2461 ]);
2462
22872463 const sidebarProjectByKey = useMemo(
22882464 () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)),
22892465 [sidebarProjects],
......@@ -2298,28 +2474,36 @@ export default function Sidebar() {
22982474 ),
22992475 [sidebarThreads],
23002476 );
2477 // Resolve the active route's project key to a logical key so it matches the
2478 // sidebar's grouped project entries.
23012479 const activeRouteProjectKey = useMemo(() => {
23022480 if (!routeThreadKey) {
23032481 return null;
23042482 }
23052483 const activeThread = sidebarThreadByKey.get(routeThreadKey);
2306 return activeThread
2307 ? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId))
2308 : null;
2309 }, [routeThreadKey, sidebarThreadByKey]);
2484 if (!activeThread) return null;
2485 const physicalKey = scopedProjectKey(
2486 scopeProjectRef(activeThread.environmentId, activeThread.projectId),
2487 );
2488 return physicalToLogicalKey.get(physicalKey) ?? physicalKey;
2489 }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]);
2490
2491 // Group threads by logical project key so all threads from grouped projects
2492 // are displayed together.
23102493 const threadsByProjectKey = useMemo(() => {
23112494 const next = new Map<string, SidebarThreadSummary[]>();
23122495 for (const thread of sidebarThreads) {
2313 const projectKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
2314 const existing = next.get(projectKey);
2496 const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
2497 const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey;
2498 const existing = next.get(logicalKey);
23152499 if (existing) {
23162500 existing.push(thread);
23172501 } else {
2318 next.set(projectKey, [thread]);
2502 next.set(logicalKey, [thread]);
23192503 }
23202504 }
23212505 return next;
2322 }, [sidebarThreads]);
2506 }, [sidebarThreads, physicalToLogicalKey]);
23232507 const getCurrentSidebarShortcutContext = useCallback(
23242508 () => ({
23252509 terminalFocus: isTerminalFocused(),
......@@ -2347,12 +2531,13 @@ export default function Sidebar() {
23472531 shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions);
23482532 const focusMostRecentThreadForProject = useCallback(
23492533 (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => {
2350 const projectKey = scopedProjectKey(
2534 const physicalKey = scopedProjectKey(
23512535 scopeProjectRef(projectRef.environmentId, projectRef.projectId),
23522536 );
2537 const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey;
23532538 const latestThread = sortThreadsForSidebar(
2354 (threadsByProjectKey.get(projectKey) ?? []).filter((thread) => thread.archivedAt === null),
2355 appSettings.sidebarThreadSortOrder,
2539 (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null),
2540 sidebarThreadSortOrder,
23562541 )[0];
23572542 if (!latestThread) return;
23582543
......@@ -2361,7 +2546,7 @@ export default function Sidebar() {
23612546 params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)),
23622547 });
23632548 },
2364 [appSettings.sidebarThreadSortOrder, navigate, threadsByProjectKey],
2549 [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey],
23652550 );
23662551
23672552 const addProjectFromPath = useCallback(
......@@ -2407,7 +2592,7 @@ export default function Sidebar() {
24072592 });
24082593 if (activeEnvironmentId !== null) {
24092594 await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), {
2410 envMode: appSettings.defaultThreadEnvMode,
2595 envMode: defaultThreadEnvMode,
24112596 }).catch(() => undefined);
24122597 }
24132598 } catch (error) {
......@@ -2434,7 +2619,7 @@ export default function Sidebar() {
24342619 isAddingProject,
24352620 projects,
24362621 shouldBrowseForProjectImmediately,
2437 appSettings.defaultThreadEnvMode,
2622 defaultThreadEnvMode,
24382623 ],
24392624 );
24402625
......@@ -2501,7 +2686,7 @@ export default function Sidebar() {
25012686
25022687 const handleProjectDragEnd = useCallback(
25032688 (event: DragEndEvent) => {
2504 if (appSettings.sidebarProjectSortOrder !== "manual") {
2689 if (sidebarProjectSortOrder !== "manual") {
25052690 dragInProgressRef.current = false;
25062691 return;
25072692 }
......@@ -2513,18 +2698,18 @@ export default function Sidebar() {
25132698 if (!activeProject || !overProject) return;
25142699 reorderProjects(activeProject.projectKey, overProject.projectKey);
25152700 },
2516 [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects],
2701 [sidebarProjectSortOrder, reorderProjects, sidebarProjects],
25172702 );
25182703
25192704 const handleProjectDragStart = useCallback(
25202705 (_event: DragStartEvent) => {
2521 if (appSettings.sidebarProjectSortOrder !== "manual") {
2706 if (sidebarProjectSortOrder !== "manual") {
25222707 return;
25232708 }
25242709 dragInProgressRef.current = true;
25252710 suppressProjectClickAfterDragRef.current = true;
25262711 },
2527 [appSettings.sidebarProjectSortOrder],
2712 [sidebarProjectSortOrder],
25282713 );
25292714
25302715 const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => {
......@@ -2558,22 +2743,29 @@ export default function Sidebar() {
25582743 ...project,
25592744 id: project.projectKey,
25602745 }));
2561 const sortableThreads = visibleThreads.map((thread) => ({
2562 ...thread,
2563 projectId: scopedProjectKey(
2564 scopeProjectRef(thread.environmentId, thread.projectId),
2565 ) as ProjectId,
2566 }));
2746 const sortableThreads = visibleThreads.map((thread) => {
2747 const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
2748 return {
2749 ...thread,
2750 projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId,
2751 };
2752 });
25672753 return sortProjectsForSidebar(
25682754 sortableProjects,
25692755 sortableThreads,
2570 appSettings.sidebarProjectSortOrder,
2756 sidebarProjectSortOrder,
25712757 ).flatMap((project) => {
25722758 const resolvedProject = sidebarProjectByKey.get(project.id);
25732759 return resolvedProject ? [resolvedProject] : [];
25742760 });
2575 }, [appSettings.sidebarProjectSortOrder, sidebarProjectByKey, sidebarProjects, visibleThreads]);
2576 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";
2761 }, [
2762 sidebarProjectSortOrder,
2763 physicalToLogicalKey,
2764 sidebarProjectByKey,
2765 sidebarProjects,
2766 visibleThreads,
2767 ]);
2768 const isManualProjectSorting = sidebarProjectSortOrder === "manual";
25772769 const visibleSidebarThreadKeys = useMemo(
25782770 () =>
25792771 sortedProjects.flatMap((project) => {
......@@ -2581,7 +2773,7 @@ export default function Sidebar() {
25812773 (threadsByProjectKey.get(project.projectKey) ?? []).filter(
25822774 (thread) => thread.archivedAt === null,
25832775 ),
2584 appSettings.sidebarThreadSortOrder,
2776 sidebarThreadSortOrder,
25852777 );
25862778 const projectExpanded = projectExpandedById[project.projectKey] ?? true;
25872779 const activeThreadKey = routeThreadKey ?? undefined;
......@@ -2609,7 +2801,7 @@ export default function Sidebar() {
26092801 );
26102802 }),
26112803 [
2612 appSettings.sidebarThreadSortOrder,
2804 sidebarThreadSortOrder,
26132805 expandedThreadListsByProject,
26142806 projectExpandedById,
26152807 routeThreadKey,
......@@ -2910,8 +3102,8 @@ export default function Sidebar() {
29103102 desktopUpdateButtonAction={desktopUpdateButtonAction}
29113103 desktopUpdateButtonDisabled={desktopUpdateButtonDisabled}
29123104 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
2913 projectSortOrder={appSettings.sidebarProjectSortOrder}
2914 threadSortOrder={appSettings.sidebarThreadSortOrder}
3105 projectSortOrder={sidebarProjectSortOrder}
3106 threadSortOrder={sidebarThreadSortOrder}
29153107 updateSettings={updateSettings}
29163108 shouldShowProjectPathEntry={shouldShowProjectPathEntry}
29173109 handleStartAddProject={handleStartAddProject}
apps/web/src/components/SplashScreen.tsx created+9
......@@ -0,0 +1,9 @@
1export 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() {
141141 };
142142}
143143
144async function mountTerminalViewport(props: { threadRef: ReturnType<typeof scopeThreadRef> }) {
144async 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
145158 const host = document.createElement("div");
146159 host.style.width = "800px";
147160 host.style.height = "400px";
148 document.body.append(host);
161 drawer.append(host);
162 document.body.append(drawer);
149163
150164 const screen = await render(
151165 <TerminalViewport
......@@ -184,7 +198,7 @@ async function mountTerminalViewport(props: { threadRef: ReturnType<typeof scope
184198 },
185199 cleanup: async () => {
186200 await screen.unmount();
187 host.remove();
201 drawer.remove();
188202 },
189203 };
190204}
......@@ -244,4 +258,32 @@ describe("TerminalViewport", () => {
244258 await mounted.cleanup();
245259 }
246260 });
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 });
247289});
apps/web/src/components/ThreadTerminalDrawer.tsx+35-7
......@@ -76,12 +76,37 @@ export function selectPendingTerminalEventEntries(
7676 return entries.filter((entry) => entry.id > lastAppliedTerminalEventId);
7777}
7878
79function terminalThemeFromApp(): ITheme {
79function 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
92function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme {
8093 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);
81101 const bodyStyles = getComputedStyle(document.body);
82 const background =
83 bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)");
84 const foreground = bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)");
102 const background = normalizeComputedColor(
103 drawerStyles.backgroundColor,
104 normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),
105 );
106 const foreground = normalizeComputedColor(
107 drawerStyles.color,
108 normalizeComputedColor(bodyStyles.color, fallbackForeground),
109 );
85110
86111 if (isDark) {
87112 return {
......@@ -275,7 +300,7 @@ export function TerminalViewport({
275300 fontSize: 12,
276301 scrollback: 5_000,
277302 fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
278 theme: terminalThemeFromApp(),
303 theme: terminalThemeFromApp(mount),
279304 });
280305 terminal.loadAddon(fitAddon);
281306 terminal.open(mount);
......@@ -488,7 +513,7 @@ export function TerminalViewport({
488513 const themeObserver = new MutationObserver(() => {
489514 const activeTerminal = terminalRef.current;
490515 if (!activeTerminal) return;
491 activeTerminal.options.theme = terminalThemeFromApp();
516 activeTerminal.options.theme = terminalThemeFromApp(containerRef.current);
492517 activeTerminal.refresh(0, activeTerminal.rows - 1);
493518 });
494519 themeObserver.observe(document.documentElement, {
......@@ -720,7 +745,10 @@ export function TerminalViewport({
720745 };
721746 }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]);
722747 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 />
724752 );
725753}
726754
apps/web/src/components/WebSocketConnectionSurface.tsx+3-173
......@@ -1,9 +1,6 @@
1import { AlertTriangle, CloudOff, LoaderCircle, RotateCw } from "lucide-react";
21import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react";
32
4import { APP_DISPLAY_NAME } from "../branding";
53import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState";
6import { useServerConfig } from "../rpc/serverState";
74import {
85 exhaustWsReconnectIfStillWaiting,
96 getWsConnectionStatus,
......@@ -14,9 +11,8 @@ import {
1411 useWsConnectionStatus,
1512 WS_RECONNECT_MAX_ATTEMPTS,
1613} from "../rpc/wsConnectionState";
17import { Button } from "./ui/button";
1814import { toastManager } from "./ui/toast";
19import { getWsRpcClient } from "~/wsRpcClient";
15import { getPrimaryEnvironmentConnection } from "../environments/runtime";
2016
2117const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000;
2218type WsAutoReconnectTrigger = "focus" | "online";
......@@ -58,11 +54,7 @@ function describeExhaustedToast(): string {
5854 return "Retries exhausted trying to reconnect";
5955}
6056
61function buildReconnectTitle(status: WsConnectionStatus): string {
62 if (status.nextRetryAt === null) {
63 return "Disconnected from T3 Server";
64 }
65
57function buildReconnectTitle(_status: WsConnectionStatus): string {
6658 return "Disconnected from T3 Server";
6759}
6860
......@@ -113,155 +105,6 @@ export function shouldAutoReconnect(
113105 );
114106}
115107
116function 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
157function 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
189function 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
265108export function WebSocketConnectionCoordinator() {
266109 const status = useWsConnectionStatus();
267110 const [nowMs, setNowMs] = useState(() => Date.now());
......@@ -277,7 +120,7 @@ export function WebSocketConnectionCoordinator() {
277120 toastResetTimerRef.current = null;
278121 }
279122 lastForcedReconnectAtRef.current = Date.now();
280 void getWsRpcClient()
123 void getPrimaryEnvironmentConnection()
281124 .reconnect()
282125 .catch((error) => {
283126 if (!showFailureToast) {
......@@ -527,18 +370,5 @@ export function SlowRpcAckToastCoordinator() {
527370}
528371
529372export 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
543373 return children;
544374}
apps/web/src/components/auth/PairingRouteSurface.tsx created+195
......@@ -0,0 +1,195 @@
1import type { AuthSessionState } from "@t3tools/contracts";
2import React, { startTransition, useEffect, useRef, useState, useCallback } from "react";
3
4import { APP_DISPLAY_NAME } from "../../branding";
5import {
6 peekPairingTokenFromUrl,
7 stripPairingTokenFromUrl,
8 submitServerAuthCredential,
9} from "../../environments/primary";
10import { Button } from "../ui/button";
11import { Input } from "../ui/input";
12
13export 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
37export 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
162function 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
174function 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
182function 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 @@
1import { PlusIcon, QrCodeIcon } from "lucide-react";
2import { memo, useCallback, useEffect, useMemo, useState } from "react";
3import {
4 type AuthClientSession,
5 type AuthPairingLink,
6 type DesktopServerExposureState,
7 type EnvironmentId,
8} from "@t3tools/contracts";
9import { DateTime } from "effect";
10
11import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
12import { cn } from "../../lib/utils";
13import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
14import {
15 SettingsPageContainer,
16 SettingsRow,
17 SettingsSection,
18 useRelativeTimeTick,
19} from "./settingsLayout";
20import { Input } from "../ui/input";
21import {
22 Dialog,
23 DialogFooter,
24 DialogDescription,
25 DialogHeader,
26 DialogPanel,
27 DialogPopup,
28 DialogTitle,
29 DialogTrigger,
30} from "../ui/dialog";
31import {
32 AlertDialog,
33 AlertDialogClose,
34 AlertDialogDescription,
35 AlertDialogFooter,
36 AlertDialogHeader,
37 AlertDialogPopup,
38 AlertDialogTitle,
39} from "../ui/alert-dialog";
40import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
41import { QRCodeSvg } from "../ui/qr-code";
42import { Spinner } from "../ui/spinner";
43import { Switch } from "../ui/switch";
44import { toastManager } from "../ui/toast";
45import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
46import { Button } from "../ui/button";
47import { Textarea } from "../ui/textarea";
48import { setPairingTokenOnUrl } from "../../pairingUrl";
49import {
50 createServerPairingCredential,
51 fetchSessionState,
52 revokeOtherServerClientSessions,
53 revokeServerClientSession,
54 revokeServerPairingLink,
55 type ServerClientSessionRecord,
56 type ServerPairingLinkRecord,
57} from "../../environments/primary";
58import type { WsRpcClient } from "~/rpc/wsRpcClient";
59import {
60 type SavedEnvironmentRecord,
61 type SavedEnvironmentRuntimeState,
62 useSavedEnvironmentRegistryStore,
63 useSavedEnvironmentRuntimeStore,
64 addSavedEnvironment,
65 getPrimaryEnvironmentConnection,
66 reconnectSavedEnvironment,
67 removeSavedEnvironment,
68} from "../../environments/runtime";
69
70const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, {
71 dateStyle: "medium",
72 timeStyle: "short",
73});
74
75function 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
83type ConnectionStatusDotProps = {
84 tooltipText?: string | null;
85 dotClassName: string;
86 pingClassName?: string | null;
87};
88
89function 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
137function 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. */
163const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5";
164
165const ITEM_ROW_INNER_CLASSNAME =
166 "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between";
167
168function sortDesktopPairingLinks(links: ReadonlyArray<ServerPairingLinkRecord>) {
169 return [...links].toSorted(
170 (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
171 );
172}
173
174function 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
186function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord {
187 return {
188 ...pairingLink,
189 createdAt: DateTime.formatIso(pairingLink.createdAt),
190 expiresAt: DateTime.formatIso(pairingLink.expiresAt),
191 };
192}
193
194function 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
206function 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
219function removeDesktopPairingLink(current: ReadonlyArray<ServerPairingLinkRecord>, id: string) {
220 return current.filter((pairingLink) => pairingLink.id !== id);
221}
222
223function 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
238function removeDesktopClientSession(
239 current: ReadonlyArray<ServerClientSessionRecord>,
240 sessionId: ServerClientSessionRecord["sessionId"],
241) {
242 return current.filter((clientSession) => clientSession.sessionId !== sessionId);
243}
244
245function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string {
246 const url = new URL(endpointUrl);
247 url.pathname = "/pair";
248 return setPairingTokenOnUrl(url, credential).toString();
249}
250
251function resolveCurrentOriginPairingUrl(credential: string): string {
252 const url = new URL("/pair", window.location.href);
253 return setPairingTokenOnUrl(url, credential).toString();
254}
255
256function 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
266type PairingLinkListRowProps = {
267 pairingLink: ServerPairingLinkRecord;
268 endpointUrl: string | null | undefined;
269 revokingPairingLinkId: string | null;
270 onRevoke: (id: string) => void;
271};
272
273const 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
451type ConnectedClientListRowProps = {
452 clientSession: ServerClientSessionRecord;
453 revokingClientSessionId: string | null;
454 onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;
455};
456
457const 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
524type AuthorizedClientsHeaderActionProps = {
525 clientSessions: ReadonlyArray<ServerClientSessionRecord>;
526 isRevokingOtherClients: boolean;
527 onRevokeOtherClients: () => void;
528};
529
530const 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
626type 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
637const 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
677type SavedBackendListRowProps = {
678 environmentId: EnvironmentId;
679 reconnectingEnvironmentId: EnvironmentId | null;
680 removingEnvironmentId: EnvironmentId | null;
681 onReconnect: (environmentId: EnvironmentId) => void;
682 onRemove: (environmentId: EnvironmentId) => void;
683};
684
685function 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
763export 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 @@
11import "../../index.css";
22
33import {
4 type AuthAccessStreamEvent,
5 type AuthAccessSnapshot,
6 AuthSessionId,
47 DEFAULT_SERVER_SETTINGS,
58 EnvironmentId,
9 type DesktopBridge,
10 type DesktopUpdateState,
611 type LocalApi,
712 type ServerConfig,
813} from "@t3tools/contracts";
14import { DateTime } from "effect";
915import { page } from "vitest/browser";
1016import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1117import { render } from "vitest-browser-react";
......@@ -13,8 +19,155 @@ import { render } from "vitest-browser-react";
1319import { __resetLocalApiForTests } from "../../localApi";
1420import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";
1521import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
22import { ConnectionsSettings } from "./ConnectionsSettings";
1623import { GeneralSettingsPanel } from "./SettingsPanels";
1724
25const 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
115vi.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
18171function createBaseServerConfig(): ServerConfig {
19172 return {
20173 environment: {
......@@ -24,6 +177,12 @@ function createBaseServerConfig(): ServerConfig {
24177 serverVersion: "0.0.0-test",
25178 capabilities: { repositoryIdentity: true },
26179 },
180 auth: {
181 policy: "loopback-browser",
182 bootstrapMethods: ["one-time-token"],
183 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
184 sessionCookieName: "t3_session",
185 },
27186 cwd: "/repo/project",
28187 keybindingsConfigPath: "/repo/project/.t3code-keybindings.json",
29188 keybindings: [],
......@@ -41,24 +200,222 @@ function createBaseServerConfig(): ServerConfig {
41200 };
42201}
43202
203function makeUtc(value: string) {
204 return DateTime.makeUnsafe(Date.parse(value));
205}
206
207function 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
223function 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
258const 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
44317describe("GeneralSettingsPanel observability", () => {
318 let mounted:
319 | (Awaited<ReturnType<typeof render>> & {
320 cleanup?: () => Promise<void>;
321 unmount?: () => Promise<void>;
322 })
323 | null = null;
324
45325 beforeEach(async () => {
46326 resetServerStateForTests();
47327 await __resetLocalApiForTests();
48328 localStorage.clear();
49 document.body.innerHTML = "";
329 authAccessHarness.reset();
50330 });
51331
52332 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 = "";
53342 resetServerStateForTests();
54343 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();
56413 });
57414
58415 it("shows diagnostics inside About with a single logs-folder action", async () => {
59416 setServerConfigSnapshot(createBaseServerConfig());
60417
61 await render(
418 mounted = await render(
62419 <AppAtomRegistryProvider>
63420 <GeneralSettingsPanel />
64421 </AppAtomRegistryProvider>,
......@@ -79,6 +436,233 @@ describe("GeneralSettingsPanel observability", () => {
79436 .toBeInTheDocument();
80437 });
81438
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
82666 it("opens the logs folder in the preferred editor", async () => {
83667 const openInEditor = vi.fn<LocalApi["shell"]["openInEditor"]>().mockResolvedValue(undefined);
84668 window.nativeApi = {
......@@ -89,7 +673,7 @@ describe("GeneralSettingsPanel observability", () => {
89673
90674 setServerConfigSnapshot(createBaseServerConfig());
91675
92 await render(
676 mounted = await render(
93677 <AppAtomRegistryProvider>
94678 <GeneralSettingsPanel />
95679 </AppAtomRegistryProvider>,
apps/web/src/components/settings/SettingsPanels.tsx+14-113
......@@ -6,11 +6,10 @@ import {
66 LoaderIcon,
77 PlusIcon,
88 RefreshCwIcon,
9 Undo2Icon,
109 XIcon,
1110} from "lucide-react";
1211import { useQueryClient } from "@tanstack/react-query";
13import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
12import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
1413import {
1514 PROVIDER_DISPLAY_NAMES,
1615 type ScopedThreadRef,
......@@ -47,9 +46,10 @@ import {
4746 resolveAppModelSelectionState,
4847} from "../../modelSelection";
4948import { ensureLocalApi, readLocalApi } from "../../localApi";
49import { useShallow } from "zustand/react/shallow";
5050import {
5151 selectProjectsAcrossEnvironments,
52 selectThreadsAcrossEnvironments,
52 selectThreadShellsAcrossEnvironments,
5353 useStore,
5454} from "../../store";
5555import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
......@@ -62,6 +62,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../
6262import { Switch } from "../ui/switch";
6363import { toastManager } from "../ui/toast";
6464import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
65import {
66 SettingResetButton,
67 SettingsPageContainer,
68 SettingsRow,
69 SettingsSection,
70 useRelativeTimeTick,
71} from "./settingsLayout";
6572import { ProjectFavicon } from "../ProjectFavicon";
6673import {
6774 useServerAvailableEditors,
......@@ -191,15 +198,6 @@ function getProviderVersionLabel(version: string | null | undefined) {
191198 return version.startsWith("v") ? version : `v${version}`;
192199}
193200
194function 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
203201function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
204202 useRelativeTimeTick();
205203 const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null;
......@@ -222,104 +220,6 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }
222220 );
223221}
224222
225function 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
252function 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
291function 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
315function 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
323223function AboutVersionTitle() {
324224 return (
325225 <span className="inline-flex items-center gap-2">
......@@ -781,6 +681,7 @@ export function GeneralSettingsPanel() {
781681 serverProviders[0]!.checkedAt,
782682 )
783683 : null;
684
784685 return (
785686 <SettingsPageContainer>
786687 <SettingsSection title="General">
......@@ -1484,8 +1385,8 @@ export function GeneralSettingsPanel() {
14841385}
14851386
14861387export function ArchivedThreadsPanel() {
1487 const projects = useStore(selectProjectsAcrossEnvironments);
1488 const threads = useStore(selectThreadsAcrossEnvironments);
1388 const projects = useStore(useShallow(selectProjectsAcrossEnvironments));
1389 const threads = useStore(useShallow(selectThreadShellsAcrossEnvironments));
14891390 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
14901391 const archivedGroups = useMemo(() => {
14911392 return projects
......@@ -1553,7 +1454,7 @@ export function ArchivedThreadsPanel() {
15531454 <SettingsSection
15541455 key={project.id}
15551456 title={project.name}
1556 icon={<ProjectFavicon cwd={project.cwd} />}
1457 icon={<ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />}
15571458 >
15581459 {projectThreads.map((thread) => (
15591460 <div
apps/web/src/components/settings/SettingsSidebarNav.tsx+9-5
......@@ -1,5 +1,5 @@
11import type { ComponentType } from "react";
2import { ArchiveIcon, ArrowLeftIcon, Settings2Icon } from "lucide-react";
2import { ArchiveIcon, ArrowLeftIcon, Link2Icon, Settings2Icon } from "lucide-react";
33import { useNavigate } from "@tanstack/react-router";
44
55import {
......@@ -12,7 +12,10 @@ import {
1212 SidebarSeparator,
1313} from "../ui/sidebar";
1414
15export type SettingsSectionPath = "/settings/general" | "/settings/archived";
15export type SettingsSectionPath =
16 | "/settings/general"
17 | "/settings/connections"
18 | "/settings/archived";
1619
1720export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
1821 label: string;
......@@ -20,6 +23,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
2023 icon: ComponentType<{ className?: string }>;
2124}> = [
2225 { label: "General", to: "/settings/general", icon: Settings2Icon },
26 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
2327 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
2428];
2529
......@@ -41,8 +45,8 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
4145 isActive={isActive}
4246 className={
4347 isActive
44 ? "gap-2 px-2 py-2 text-left text-xs text-foreground"
45 : "gap-2 px-2 py-2 text-left text-xs text-muted-foreground hover:text-foreground/80"
48 ? "gap-2.5 px-2.5 py-2 text-left text-[13px] font-medium text-foreground"
49 : "gap-2.5 px-2.5 py-2 text-left text-[13px] text-muted-foreground/70 hover:text-foreground/80"
4650 }
4751 onClick={() => void navigate({ to: item.to, replace: true })}
4852 >
......@@ -50,7 +54,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
5054 className={
5155 isActive
5256 ? "size-4 shrink-0 text-foreground"
53 : "size-4 shrink-0 text-muted-foreground"
57 : "size-4 shrink-0 text-muted-foreground/60"
5458 }
5559 />
5660 <span className="truncate">{item.label}</span>
apps/web/src/components/settings/settingsLayout.tsx created+122
......@@ -0,0 +1,122 @@
1import { Undo2Icon } from "lucide-react";
2import { type ReactNode, useEffect, useState } from "react";
3
4import { cn } from "../../lib/utils";
5import { Button } from "../ui/button";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7
8/** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */
9export 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
18export 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
46export 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
92export 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
116export 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 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vitest";
3
4import { QRCodeSvg } from "./qr-code";
5
6describe("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 @@
1import { memo } from "react";
2import { QrCode } from "../../lib/vendor/qrcodegen";
3
4type 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
15const 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
22function 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
52export 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", () => {
800800 envMode: "worktree",
801801 });
802802 });
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 });
803847});
804848
805849describe("composerDraftStore modelSelection", () => {
apps/web/src/composerDraftStore.ts+39-7
......@@ -1001,10 +1001,22 @@ function createDraftThreadState(
10011001 interactionMode?: ProviderInteractionMode;
10021002 },
10031003): DraftThreadState {
1004 const projectChanged =
1005 existingThread !== undefined &&
1006 (existingThread.environmentId !== projectRef.environmentId ||
1007 existingThread.projectId !== projectRef.projectId);
10041008 const nextWorktreePath =
10051009 options?.worktreePath === undefined
1006 ? (existingThread?.worktreePath ?? null)
1010 ? projectChanged
1011 ? null
1012 : (existingThread?.worktreePath ?? null)
10071013 : (options.worktreePath ?? null);
1014 const nextBranch =
1015 options?.branch === undefined
1016 ? projectChanged
1017 ? null
1018 : (existingThread?.branch ?? null)
1019 : (options.branch ?? null);
10081020 return {
10091021 threadId,
10101022 environmentId: projectRef.environmentId,
......@@ -1014,11 +1026,15 @@ function createDraftThreadState(
10141026 runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
10151027 interactionMode:
10161028 options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE,
1017 branch:
1018 options?.branch === undefined ? (existingThread?.branch ?? null) : (options.branch ?? null),
1029 branch: nextBranch,
10191030 worktreePath: nextWorktreePath,
10201031 envMode:
1021 options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")),
1032 options?.envMode ??
1033 (nextWorktreePath
1034 ? "worktree"
1035 : projectChanged
1036 ? "local"
1037 : (existingThread?.envMode ?? "local")),
10221038 promotedTo: null,
10231039 };
10241040}
......@@ -1908,10 +1924,21 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
19081924 ) {
19091925 return state;
19101926 }
1927 const projectChanged =
1928 nextProjectRef.environmentId !== existing.environmentId ||
1929 nextProjectRef.projectId !== existing.projectId;
19111930 const nextWorktreePath =
19121931 options.worktreePath === undefined
1913 ? existing.worktreePath
1932 ? projectChanged
1933 ? null
1934 : existing.worktreePath
19141935 : (options.worktreePath ?? null);
1936 const nextBranch =
1937 options.branch === undefined
1938 ? projectChanged
1939 ? null
1940 : existing.branch
1941 : (options.branch ?? null);
19151942 const nextDraftThread: DraftThreadState = {
19161943 threadId: existing.threadId,
19171944 environmentId: nextProjectRef.environmentId,
......@@ -1923,10 +1950,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
19231950 : options.createdAt || existing.createdAt,
19241951 runtimeMode: options.runtimeMode ?? existing.runtimeMode,
19251952 interactionMode: options.interactionMode ?? existing.interactionMode,
1926 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
1953 branch: nextBranch,
19271954 worktreePath: nextWorktreePath,
19281955 envMode:
1929 options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")),
1956 options.envMode ??
1957 (nextWorktreePath
1958 ? "worktree"
1959 : projectChanged
1960 ? "local"
1961 : (existing.envMode ?? "local")),
19301962 promotedTo: existing.promotedTo ?? null,
19311963 };
19321964 const isUnchanged =
apps/web/src/environmentApi.ts+4-3
......@@ -1,6 +1,7 @@
11import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts";
22
3import { readWsRpcClientEntryForEnvironment, WsRpcClient } from "./wsRpcClient";
3import type { WsRpcClient } from "./rpc/wsRpcClient";
4import { readEnvironmentConnection } from "./environments/runtime";
45
56export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
67 return {
......@@ -54,8 +55,8 @@ export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi
5455 return undefined;
5556 }
5657
57 const entry = readWsRpcClientEntryForEnvironment(environmentId);
58 return entry ? createEnvironmentApi(entry.client) : undefined;
58 const connection = readEnvironmentConnection(environmentId);
59 return connection ? createEnvironmentApi(connection.client) : undefined;
5960}
6061
6162export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi {
apps/web/src/environmentBootstrap.ts deleted-65
......@@ -1,65 +0,0 @@
1import {
2 createKnownEnvironmentFromWsUrl,
3 getKnownEnvironmentBaseUrl,
4 type KnownEnvironment,
5} from "@t3tools/client-runtime";
6import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts";
7
8function 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
23export 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
59export 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 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { scopeProjectRef } from "@t3tools/client-runtime";
3import { describe, expect, it } from "vitest";
4
5import {
6 selectProjectsAcrossEnvironments,
7 selectSidebarThreadsAcrossEnvironments,
8 selectSidebarThreadsForProjectRef,
9 selectSidebarThreadsForProjectRefs,
10 type AppState,
11 type EnvironmentState,
12} from "./store";
13import { deriveLogicalProjectKey } from "./logicalProject";
14import type { Project, SidebarThreadSummary } from "./types";
15import { DEFAULT_INTERACTION_MODE } from "./types";
16
17// ── Fixture Identifiers ──────────────────────────────────────────────
18
19const primaryEnvId = EnvironmentId.makeUnsafe("env-primary");
20const remoteEnvId = EnvironmentId.makeUnsafe("env-remote");
21
22const sharedProjectPrimaryId = ProjectId.makeUnsafe("shared-proj-primary");
23const sharedProjectRemoteId = ProjectId.makeUnsafe("shared-proj-remote");
24const localOnlyProjectId = ProjectId.makeUnsafe("local-only-proj");
25const remoteOnlyProjectId = ProjectId.makeUnsafe("remote-only-proj");
26
27const threadP1 = ThreadId.makeUnsafe("thread-shared-primary-1");
28const threadP2 = ThreadId.makeUnsafe("thread-shared-primary-2");
29const threadR1 = ThreadId.makeUnsafe("thread-shared-remote-1");
30const threadL1 = ThreadId.makeUnsafe("thread-local-only-1");
31const threadRO1 = ThreadId.makeUnsafe("thread-remote-only-1");
32
33const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo";
34
35// ── Factory Helpers ──────────────────────────────────────────────────
36
37function 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
50function 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
71function 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
95function 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
216describe("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 @@
1import type {
2 AuthBootstrapInput,
3 AuthBootstrapResult,
4 AuthClientMetadata,
5 AuthCreatePairingCredentialInput,
6 AuthPairingCredentialResult,
7 AuthRevokeClientSessionInput,
8 AuthRevokePairingLinkInput,
9 AuthSessionId,
10 AuthSessionState,
11} from "@t3tools/contracts";
12
13import {
14 getPairingTokenFromUrl,
15 stripPairingTokenFromUrl as stripPairingTokenUrl,
16} from "../../pairingUrl";
17
18import { resolvePrimaryEnvironmentHttpUrl } from "./target";
19
20export 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
30export 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
43type ServerAuthGateState =
44 | { status: "authenticated" }
45 | {
46 status: "requires-auth";
47 auth: AuthSessionState["auth"];
48 errorMessage?: string;
49 };
50
51let bootstrapPromise: Promise<ServerAuthGateState> | null = null;
52const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000;
53const AUTH_SESSION_ESTABLISH_STEP_MS = 100;
54
55export function peekPairingTokenFromUrl(): string | null {
56 return getPairingTokenFromUrl(new URL(window.location.href));
57}
58
59export 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
68export function takePairingTokenFromUrl(): string | null {
69 const token = peekPairingTokenFromUrl();
70 if (!token) {
71 return null;
72 }
73 stripPairingTokenFromUrl();
74 return token;
75}
76
77function 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
84export 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
99async function readErrorMessage(response: Response, fallbackMessage: string): Promise<string> {
100 const text = await response.text();
101 return text || fallbackMessage;
102}
103
104async 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
128async 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
145const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]);
146const BOOTSTRAP_RETRY_TIMEOUT_MS = 15_000;
147const BOOTSTRAP_RETRY_STEP_MS = 500;
148
149export 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
159export 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
178function waitForBootstrapRetry(delayMs: number): Promise<void> {
179 return new Promise((resolve) => {
180 setTimeout(resolve, delayMs);
181 });
182}
183
184function 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
196async 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
223export 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
234export 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
257export 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
271export 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
289export 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
305export 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
323export 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
345export 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
359export function __resetServerAuthBootstrapForTests() {
360 bootstrapPromise = null;
361}
apps/web/src/environments/primary/bootstrap.test.ts created+121
......@@ -0,0 +1,121 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3
4import {
5 getPrimaryKnownEnvironment,
6 resolveInitialPrimaryEnvironmentDescriptor,
7 resetPrimaryEnvironmentDescriptorForTests,
8 writePrimaryEnvironmentDescriptor,
9} from ".";
10
11function 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
21const 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
34function installTestBrowser(url: string) {
35 vi.stubGlobal("window", {
36 location: new URL(url),
37 history: {
38 replaceState: vi.fn(),
39 },
40 });
41}
42
43describe("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 @@
1import {
2 attachEnvironmentDescriptor,
3 createKnownEnvironment,
4 type KnownEnvironment,
5} from "@t3tools/client-runtime";
6import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
7import { create } from "zustand";
8
9import { BootstrapHttpError, retryTransientBootstrap } from "./auth";
10
11import { readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl } from "./target";
12
13const SERVER_ENVIRONMENT_DESCRIPTOR_PATH = "/.well-known/t3/environment";
14
15interface PrimaryEnvironmentBootstrapState {
16 readonly descriptor: ExecutionEnvironmentDescriptor | null;
17 readonly setDescriptor: (descriptor: ExecutionEnvironmentDescriptor | null) => void;
18 readonly reset: () => void;
19}
20
21const usePrimaryEnvironmentBootstrapStore = create<PrimaryEnvironmentBootstrapState>()((set) => ({
22 descriptor: null,
23 setDescriptor: (descriptor) => set({ descriptor }),
24 reset: () => set({ descriptor: null }),
25}));
26
27let primaryEnvironmentDescriptorPromise: Promise<ExecutionEnvironmentDescriptor> | null = null;
28
29function 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
49async 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
67export function readPrimaryEnvironmentDescriptor(): ExecutionEnvironmentDescriptor | null {
68 return usePrimaryEnvironmentBootstrapStore.getState().descriptor;
69}
70
71export function usePrimaryEnvironmentId(): EnvironmentId | null {
72 return usePrimaryEnvironmentBootstrapStore((state) => state.descriptor?.environmentId ?? null);
73}
74
75export function writePrimaryEnvironmentDescriptor(
76 descriptor: ExecutionEnvironmentDescriptor | null,
77): void {
78 usePrimaryEnvironmentBootstrapStore.getState().setDescriptor(descriptor);
79}
80
81export 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
93export 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
112export function __resetPrimaryEnvironmentBootstrapForTests(): void {
113 primaryEnvironmentDescriptorPromise = null;
114 usePrimaryEnvironmentBootstrapStore.getState().reset();
115}
116
117export const resetPrimaryEnvironmentDescriptorForTests = __resetPrimaryEnvironmentBootstrapForTests;
118
119export const __resetPrimaryEnvironmentDescriptorBootstrapForTests =
120 __resetPrimaryEnvironmentBootstrapForTests;
apps/web/src/environments/primary/index.ts created+35
......@@ -0,0 +1,35 @@
1export {
2 getPrimaryKnownEnvironment,
3 readPrimaryEnvironmentDescriptor,
4 resetPrimaryEnvironmentDescriptorForTests,
5 resolveInitialPrimaryEnvironmentDescriptor,
6 usePrimaryEnvironmentId,
7 writePrimaryEnvironmentDescriptor,
8 __resetPrimaryEnvironmentBootstrapForTests,
9 __resetPrimaryEnvironmentDescriptorBootstrapForTests,
10} from "./context";
11
12export {
13 resolveInitialPrimaryEnvironmentDescriptor as ensurePrimaryEnvironmentReady,
14 writePrimaryEnvironmentDescriptor as updatePrimaryEnvironmentDescriptor,
15} from "./context";
16
17export {
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
35export { resolvePrimaryEnvironmentHttpUrl } from "./target";
apps/web/src/environments/primary/target.ts created+103
......@@ -0,0 +1,103 @@
1import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts";
2import type { KnownEnvironment } from "@t3tools/client-runtime";
3
4export interface PrimaryEnvironmentTarget {
5 readonly source: KnownEnvironment["source"];
6 readonly target: KnownEnvironment["target"];
7}
8
9function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null {
10 return window.desktopBridge?.getLocalEnvironmentBootstrap() ?? null;
11}
12
13function normalizeBaseUrl(rawValue: string): string {
14 return new URL(rawValue, window.location.origin).toString();
15}
16
17function 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
38function 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
57function 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
80export 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
97export 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 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3import {
4 bootstrapRemoteBearerSession,
5 fetchRemoteEnvironmentDescriptor,
6 fetchRemoteSessionState,
7 issueRemoteWebSocketToken,
8 resolveRemoteWebSocketConnectionUrl,
9} from "./api";
10import { resolveRemotePairingTarget } from "./target";
11
12const originalFetch = globalThis.fetch;
13
14beforeEach(() => {
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
26describe("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
233afterEach(() => {
234 globalThis.fetch = originalFetch;
235});
apps/web/src/environments/remote/api.ts created+143
......@@ -0,0 +1,143 @@
1import type {
2 AuthBearerBootstrapResult,
3 AuthSessionState,
4 AuthWebSocketTokenResult,
5 ExecutionEnvironmentDescriptor,
6} from "@t3tools/contracts";
7
8class 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
18function 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
26async 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
47async 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
85export 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
99export 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
110export 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
119export 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
131export 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 @@
1import { getPairingTokenFromUrl } from "../../pairingUrl";
2
3export interface ResolvedRemotePairingTarget {
4 readonly credential: string;
5 readonly httpBaseUrl: string;
6 readonly wsBaseUrl: string;
7}
8
9function 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
26function 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
39function 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
52export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { afterEach, describe, expect, it } from "vitest";
3
4import {
5 resetSavedEnvironmentRegistryStoreForTests,
6 resetSavedEnvironmentRuntimeStoreForTests,
7 useSavedEnvironmentRegistryStore,
8 useSavedEnvironmentRuntimeStore,
9} from "./catalog";
10
11describe("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 @@
1import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime";
2import type {
3 AuthSessionRole,
4 EnvironmentId,
5 ExecutionEnvironmentDescriptor,
6 ServerConfig,
7} from "@t3tools/contracts";
8import { create } from "zustand";
9import { createJSONStorage, persist } from "zustand/middleware";
10
11import { resolveStorage } from "../../lib/storage";
12import { getPrimaryKnownEnvironment } from "../primary";
13
14const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1";
15
16export 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
26interface SavedEnvironmentRegistryState {
27 readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>;
28}
29
30interface 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
37function createSavedEnvironmentRegistryStorage() {
38 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
39}
40
41export 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
91export function hasSavedEnvironmentRegistryHydrated(): boolean {
92 return useSavedEnvironmentRegistryStore.persist.hasHydrated();
93}
94
95export 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
113export function listSavedEnvironmentRecords(): ReadonlyArray<SavedEnvironmentRecord> {
114 return Object.values(useSavedEnvironmentRegistryStore.getState().byId).toSorted((left, right) =>
115 left.label.localeCompare(right.label),
116 );
117}
118
119export function getSavedEnvironmentRecord(
120 environmentId: EnvironmentId,
121): SavedEnvironmentRecord | null {
122 return useSavedEnvironmentRegistryStore.getState().byId[environmentId] ?? null;
123}
124
125export 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
134export 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
152export function resetSavedEnvironmentRegistryStoreForTests() {
153 useSavedEnvironmentRegistryStore.getState().reset();
154}
155
156export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error";
157
158export type SavedEnvironmentAuthState = "authenticated" | "requires-auth" | "unknown";
159
160export 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
172interface 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
183const 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
195function createDefaultSavedEnvironmentRuntimeState(): SavedEnvironmentRuntimeState {
196 return {
197 ...DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE,
198 };
199}
200
201export 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
240export function getSavedEnvironmentRuntimeState(
241 environmentId: EnvironmentId,
242): SavedEnvironmentRuntimeState {
243 return (
244 useSavedEnvironmentRuntimeStore.getState().byId[environmentId] ??
245 DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE
246 );
247}
248
249export function resetSavedEnvironmentRuntimeStoreForTests() {
250 useSavedEnvironmentRuntimeStore.getState().reset();
251}
apps/web/src/environments/runtime/connection.test.ts created+216
......@@ -0,0 +1,216 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vitest";
3
4import { createEnvironmentConnection } from "./connection";
5import type { WsRpcClient } from "~/rpc/wsRpcClient";
6
7function 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
120describe("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 @@
1import type {
2 EnvironmentId,
3 OrchestrationEvent,
4 OrchestrationReadModel,
5 ServerConfig,
6 ServerLifecycleWelcomePayload,
7 TerminalEvent,
8} from "@t3tools/contracts";
9import type { KnownEnvironment } from "@t3tools/client-runtime";
10
11import {
12 deriveReplayRetryDecision,
13 type OrchestrationRecoveryReason,
14} from "../../orchestrationRecovery";
15import {
16 createOrchestrationRecoveryCoordinator,
17 type ReplayRetryTracker,
18} from "../../orchestrationRecovery";
19import type { WsRpcClient } from "~/rpc/wsRpcClient";
20
21const REPLAY_RECOVERY_RETRY_DELAY_MS = 100;
22const MAX_NO_PROGRESS_REPLAY_RETRIES = 3;
23
24export 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
34interface 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
43interface 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
52function 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
83export 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 @@
1export {
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
17export {
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 @@
1import {
2 type AuthSessionRole,
3 type EnvironmentId,
4 type OrchestrationEvent,
5 type OrchestrationReadModel,
6 type ServerConfig,
7 type TerminalEvent,
8 ThreadId,
9} from "@t3tools/contracts";
10import { type QueryClient } from "@tanstack/react-query";
11import { Throttler } from "@tanstack/react-pacer";
12import {
13 createKnownEnvironment,
14 getKnownEnvironmentWsBaseUrl,
15 scopedProjectKey,
16 scopedThreadKey,
17 scopeProjectRef,
18 scopeThreadRef,
19} from "@t3tools/client-runtime";
20
21import {
22 markPromotedDraftThreadByRef,
23 markPromotedDraftThreadsByRef,
24 useComposerDraftStore,
25} from "~/composerDraftStore";
26import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup";
27import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
28import { projectQueryKeys } from "~/lib/projectReactQuery";
29import { providerQueryKeys } from "~/lib/providerReactQuery";
30import { getPrimaryKnownEnvironment } from "../primary";
31import {
32 bootstrapRemoteBearerSession,
33 fetchRemoteEnvironmentDescriptor,
34 fetchRemoteSessionState,
35 resolveRemoteWebSocketConnectionUrl,
36} from "../remote/api";
37import { resolveRemotePairingTarget } from "../remote/target";
38import {
39 getSavedEnvironmentRecord,
40 hasSavedEnvironmentRegistryHydrated,
41 listSavedEnvironmentRecords,
42 type SavedEnvironmentRecord,
43 useSavedEnvironmentRegistryStore,
44 useSavedEnvironmentRuntimeStore,
45 waitForSavedEnvironmentRegistryHydration,
46} from "./catalog";
47import { createEnvironmentConnection, type EnvironmentConnection } from "./connection";
48import {
49 useStore,
50 selectProjectsAcrossEnvironments,
51 selectThreadByRef,
52 selectThreadsAcrossEnvironments,
53} from "~/store";
54import { useTerminalStateStore } from "~/terminalStateStore";
55import { useUiStateStore } from "~/uiStateStore";
56import { WsTransport } from "../../rpc/wsTransport";
57import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";
58
59type EnvironmentServiceState = {
60 readonly queryClient: QueryClient;
61 readonly queryInvalidationThrottler: Throttler<() => void>;
62 refCount: number;
63 stop: () => void;
64};
65
66const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>();
67const environmentConnectionListeners = new Set<() => void>();
68
69let activeService: EnvironmentServiceState | null = null;
70let needsProviderInvalidation = false;
71
72function emitEnvironmentConnectionRegistryChange() {
73 for (const listener of environmentConnectionListeners) {
74 listener();
75 }
76}
77
78function getRuntimeErrorFields(error: unknown) {
79 return {
80 lastError: error instanceof Error ? error.message : String(error),
81 lastErrorAt: new Date().toISOString(),
82 } as const;
83}
84
85function isoNow(): string {
86 return new Date().toISOString();
87}
88
89function setRuntimeConnecting(environmentId: EnvironmentId) {
90 useSavedEnvironmentRuntimeStore.getState().patch(environmentId, {
91 connectionState: "connecting",
92 lastError: null,
93 lastErrorAt: null,
94 });
95}
96
97function 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
110function 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
123function setRuntimeError(environmentId: EnvironmentId, error: unknown) {
124 useSavedEnvironmentRuntimeStore.getState().patch(environmentId, {
125 connectionState: "error",
126 ...getRuntimeErrorFields(error),
127 });
128}
129
130function 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
167function 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
199function 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
260function 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
278function 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
291function 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
324async 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
346function 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
356async 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
368function 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
389async 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
453async 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
470function stopActiveService() {
471 activeService?.stop();
472 activeService = null;
473}
474
475export function subscribeEnvironmentConnections(listener: () => void): () => void {
476 environmentConnectionListeners.add(listener);
477 return () => {
478 environmentConnectionListeners.delete(listener);
479 };
480}
481
482export function listEnvironmentConnections(): ReadonlyArray<EnvironmentConnection> {
483 return [...environmentConnections.values()];
484}
485
486export function readEnvironmentConnection(
487 environmentId: EnvironmentId,
488): EnvironmentConnection | null {
489 return environmentConnections.get(environmentId) ?? null;
490}
491
492export 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
500export function getPrimaryEnvironmentConnection(): EnvironmentConnection {
501 return createPrimaryEnvironmentConnection();
502}
503
504export 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
514export 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
535export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> {
536 useSavedEnvironmentRegistryStore.getState().remove(environmentId);
537 await disconnectSavedEnvironment(environmentId);
538}
539
540export 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
582export async function ensureEnvironmentConnectionBootstrapped(
583 environmentId: EnvironmentId,
584): Promise<void> {
585 await environmentConnections.get(environmentId)?.ensureBootstrapped();
586}
587
588export 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
654export 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 = {
88
99const STORAGE_KEY = "t3code:theme";
1010const MEDIA_QUERY = "(prefers-color-scheme: dark)";
11const THEME_COLOR_META_NAME = "theme-color";
12const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
1113
1214let listeners: Array<() => void> = [];
1315let lastSnapshot: ThemeSnapshot | null = null;
......@@ -26,12 +28,63 @@ function getStored(): Theme {
2628 return "system";
2729}
2830
31function 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
44function 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
58function 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
66export 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
2980function applyTheme(theme: Theme, suppressTransitions = false) {
81 if (typeof document === "undefined") return;
3082 if (suppressTransitions) {
3183 document.documentElement.classList.add("no-transitions");
3284 }
3385 const isDark = theme === "dark" || (theme === "system" && getSystemDark());
3486 document.documentElement.classList.toggle("dark", isDark);
87 syncBrowserChromeTheme();
3588 syncDesktopTheme(theme);
3689 if (suppressTransitions) {
3790 // Force a reflow so the no-transitions class takes effect before removal
apps/web/src/index.css+8-4
......@@ -47,8 +47,12 @@
4747 * {
4848 @apply border-border outline-ring/50;
4949 }
50 html {
51 background-color: var(--app-chrome-background);
52 }
5053 body {
51 @apply bg-background text-foreground relative;
54 @apply text-foreground relative;
55 background-color: var(--app-chrome-background);
5256 }
5357}
5458
......@@ -65,6 +69,7 @@
6569 color-scheme: light;
6670 --radius: 0.625rem;
6771 --background: var(--color-white);
72 --app-chrome-background: var(--background);
6873 --foreground: var(--color-neutral-800);
6974 --card: var(--color-white);
7075 --card-foreground: var(--color-neutral-800);
......@@ -93,6 +98,7 @@
9398 @variant dark {
9499 color-scheme: dark;
95100 --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white));
101 --app-chrome-background: var(--background);
96102 --foreground: var(--color-neutral-100);
97103 --card: color-mix(in srgb, var(--background) 98%, var(--color-white));
98104 --card-foreground: var(--color-neutral-100);
......@@ -157,9 +163,7 @@ body::after {
157163}
158164
159165pre,
160code,
161textarea,
162input {
166code {
163167 font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
164168}
165169
apps/web/src/lib/gitReactQuery.ts+2-2
......@@ -11,7 +11,7 @@ import {
1111 type QueryClient,
1212} from "@tanstack/react-query";
1313import { ensureEnvironmentApi } from "../environmentApi";
14import { getWsRpcClientForEnvironment } from "../wsRpcClient";
14import { requireEnvironmentConnection } from "../environments/runtime";
1515
1616const GIT_BRANCHES_STALE_TIME_MS = 15_000;
1717const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
......@@ -180,7 +180,7 @@ export function gitRunStackedActionMutationOptions(input: {
180180 onProgress?: (event: GitActionProgressEvent) => void;
181181 }) => {
182182 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(
184184 {
185185 action,
186186 actionId,
apps/web/src/lib/gitStatusState.test.ts+177-1
......@@ -1,6 +1,8 @@
11import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts";
22import { afterEach, describe, expect, it, vi } from "vitest";
33
4import type { WsRpcClient } from "../rpc/wsRpcClient";
5import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry";
46import {
57 getGitStatusSnapshot,
68 resetGitStatusStateForTests,
......@@ -8,6 +10,22 @@ import {
810 watchGitStatus,
911} from "./gitStatusState";
1012
13const serviceHarness = vi.hoisted(() => ({
14 connections: new Map<string, any>(),
15 listeners: new Set<() => void>(),
16}));
17
18vi.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
1129function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
1230 listeners.add(listener);
1331 return () => {
......@@ -50,11 +68,110 @@ function emitGitStatus(event: GitStatusResult) {
5068 }
5169}
5270
53afterEach(() => {
71function 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
167afterEach(async () => {
54168 gitStatusListeners.clear();
169 serviceHarness.connections.clear();
170 serviceHarness.listeners.clear();
55171 gitClient.onStatus.mockClear();
56172 gitClient.refreshStatus.mockClear();
57173 resetGitStatusStateForTests();
174 resetAppAtomRegistryForTests();
58175});
59176
60177describe("gitStatusState", () => {
......@@ -147,4 +264,63 @@ describe("gitStatusState", () => {
147264 releaseLocal();
148265 releaseRemote();
149266 });
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 });
150326});
apps/web/src/lib/gitStatusState.ts+83-21
......@@ -9,25 +9,31 @@ import { Atom } from "effect/unstable/reactivity";
99import { useEffect } from "react";
1010
1111import { appAtomRegistry } from "../rpc/atomRegistry";
12import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient";
13
14export type GitStatusStreamError = GitManagerServiceError;
12import {
13 readEnvironmentConnection,
14 subscribeEnvironmentConnections,
15} from "../environments/runtime";
16import type { WsRpcClient } from "~/rpc/wsRpcClient";
1517
16export interface GitStatusState {
18interface GitStatusState {
1719 readonly data: GitStatusResult | null;
18 readonly error: GitStatusStreamError | null;
19 readonly cause: Cause.Cause<GitStatusStreamError> | null;
20 readonly error: GitManagerServiceError | null;
21 readonly cause: Cause.Cause<GitManagerServiceError> | null;
2022 readonly isPending: boolean;
2123}
2224
2325type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">;
26interface ResolvedGitStatusClient {
27 readonly clientIdentity: string;
28 readonly client: GitStatusClient;
29}
2430
2531interface WatchedGitStatus {
2632 refCount: number;
2733 unsubscribe: () => void;
2834}
2935
30export interface GitStatusTarget {
36interface GitStatusTarget {
3137 readonly environmentId: EnvironmentId | null;
3238 readonly cwd: string | null;
3339}
......@@ -64,18 +70,21 @@ const gitStatusStateAtom = Atom.family((key: string) => {
6470});
6571
6672function getGitStatusTargetKey(target: GitStatusTarget): string | null {
67 if (target.cwd === null) {
73 if (target.environmentId === null || target.cwd === null) {
6874 return null;
6975 }
7076
71 return `${target.environmentId ?? "__default__"}:${target.cwd}`;
77 return `${target.environmentId}:${target.cwd}`;
7278}
7379
74function resolveGitStatusClient(target: GitStatusTarget): GitStatusClient {
75 if (target.environmentId) {
76 return getWsRpcClientForEnvironment(target.environmentId).git;
80function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatusClient | null {
81 if (target.environmentId === null) {
82 return null;
7783 }
78 return getWsRpcClient().git;
84 const connection = readEnvironmentConnection(target.environmentId);
85 return connection
86 ? { clientIdentity: connection.environmentId, client: connection.client.git }
87 : null;
7988}
8089
8190export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState {
......@@ -87,10 +96,7 @@ export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState {
8796 return appAtomRegistry.get(gitStatusStateAtom(targetKey));
8897}
8998
90export function watchGitStatus(
91 target: GitStatusTarget,
92 client: GitStatusClient = resolveGitStatusClient(target),
93): () => void {
99export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient): () => void {
94100 const targetKey = getGitStatusTargetKey(target);
95101 if (targetKey === null) {
96102 return NOOP;
......@@ -104,7 +110,7 @@ export function watchGitStatus(
104110
105111 watchedGitStatuses.set(targetKey, {
106112 refCount: 1,
107 unsubscribe: subscribeToGitStatus(targetKey, target.cwd!, client),
113 unsubscribe: subscribeToGitStatusTarget(targetKey, target, client),
108114 });
109115
110116 return () => unwatchGitStatus(targetKey);
......@@ -112,13 +118,18 @@ export function watchGitStatus(
112118
113119export function refreshGitStatus(
114120 target: GitStatusTarget,
115 client: GitStatusClient = resolveGitStatusClient(target),
121 client?: GitStatusClient,
116122): Promise<GitStatusResult | null> {
117123 const targetKey = getGitStatusTargetKey(target);
118124 if (targetKey === null || target.cwd === null) {
119125 return Promise.resolve(null);
120126 }
121127
128 const resolvedClient = client ?? readResolvedGitStatusClient(target)?.client;
129 if (!resolvedClient) {
130 return Promise.resolve(getGitStatusSnapshot(target).data);
131 }
132
122133 const currentInFlight = gitStatusRefreshInFlight.get(targetKey);
123134 if (currentInFlight) {
124135 return currentInFlight;
......@@ -130,7 +141,7 @@ export function refreshGitStatus(
130141 }
131142
132143 gitStatusLastRefreshAtByKey.set(targetKey, Date.now());
133 const refreshPromise = client.refreshStatus({ cwd: target.cwd }).finally(() => {
144 const refreshPromise = resolvedClient.refreshStatus({ cwd: target.cwd }).finally(() => {
134145 gitStatusRefreshInFlight.delete(targetKey);
135146 });
136147 gitStatusRefreshInFlight.set(targetKey, refreshPromise);
......@@ -179,11 +190,62 @@ function unwatchGitStatus(targetKey: string): void {
179190 watchedGitStatuses.delete(targetKey);
180191}
181192
193function 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
182244function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void {
183245 markGitStatusPending(targetKey);
184246 return client.onStatus(
185247 { cwd },
186 (status) => {
248 (status: GitStatusResult) => {
187249 appAtomRegistry.set(gitStatusStateAtom(targetKey), {
188250 data: status,
189251 error: null,
apps/web/src/lib/utils.test.ts+1-42
......@@ -1,15 +1,5 @@
1import { assert, describe, expect, it, vi } from "vitest";
2
3const { resolvePrimaryEnvironmentBootstrapUrlMock } = vi.hoisted(() => ({
4 resolvePrimaryEnvironmentBootstrapUrlMock: vi.fn(() => "http://bootstrap.test:4321"),
5}));
6
7vi.mock("../environmentBootstrap", () => ({
8 resolvePrimaryEnvironmentBootstrapUrl: resolvePrimaryEnvironmentBootstrapUrlMock,
9}));
10
1import { describe, assert, it } from "vitest";
112import { isWindowsPlatform } from "./utils";
12import { resolveServerUrl } from "./utils";
133
144describe("isWindowsPlatform", () => {
155 it("matches Windows platform identifiers", () => {
......@@ -22,34 +12,3 @@ describe("isWindowsPlatform", () => {
2212 assert.isFalse(isWindowsPlatform("darwin"));
2313 });
2414});
25
26describe("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 @@
11import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { String, Predicate } from "effect";
32import { type CxOptions, cx } from "class-variance-authority";
43import { twMerge } from "tailwind-merge";
54import * as Random from "effect/Random";
65import * as Effect from "effect/Effect";
7import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap";
86import { DraftId } from "../composerDraftStore";
97
108export function cn(...inputs: CxOptions) {
......@@ -39,30 +37,3 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());
3937export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID());
4038
4139export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());
42
43const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty);
44
45export 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
29namespace 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
923namespace 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
956namespace 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
995export const QrCode = qrcodegen.QrCode;
996export const QrSegment = qrcodegen.QrSegment;
apps/web/src/localApi.test.ts+44-22
......@@ -79,6 +79,7 @@ const rpcClientMock = {
7979 updateSettings: vi.fn(),
8080 subscribeConfig: vi.fn(),
8181 subscribeLifecycle: vi.fn(),
82 subscribeAuthAccess: vi.fn(),
8283 },
8384 orchestration: {
8485 getSnapshot: vi.fn(),
......@@ -92,23 +93,29 @@ const rpcClientMock = {
9293 },
9394};
9495
95vi.mock("./wsRpcClient", () => {
96 return {
97 getWsRpcClient: () => rpcClientMock,
98 getPrimaryWsRpcClientEntry: () => ({
99 key: "primary",
100 knownEnvironment: {
101 id: "primary",
102 label: "Primary",
103 source: "manual",
104 target: { type: "ws", wsUrl: "ws://localhost:3000" },
96vi.mock("./environments/runtime", () => ({
97 getPrimaryEnvironmentConnection: () => ({
98 kind: "primary" as const,
99 knownEnvironment: {
100 id: "environment-local",
101 label: "Primary",
102 source: "manual" as const,
103 target: {
104 httpBaseUrl: "http://localhost:3000",
105 wsBaseUrl: "ws://localhost:3000",
105106 },
106 client: rpcClientMock,
107 environmentId: null,
108 }),
109 __resetWsRpcClientForTests: vi.fn(),
110 };
111});
107 environmentId: EnvironmentId.makeUnsafe("environment-local"),
108 },
109 client: rpcClientMock,
110 environmentId: EnvironmentId.makeUnsafe("environment-local"),
111 ensureBootstrapped: async () => undefined,
112 reconnect: async () => undefined,
113 dispose: async () => undefined,
114 }),
115 resetEnvironmentServiceForTests: vi.fn(),
116 resetSavedEnvironmentRegistryStoreForTests: vi.fn(),
117 resetSavedEnvironmentRuntimeStoreForTests: vi.fn(),
118}));
112119
113120vi.mock("./contextMenuFallback", () => ({
114121 showContextMenuFallback: showContextMenuFallbackMock,
......@@ -132,8 +139,17 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn
132139
133140function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {
134141 return {
135 getWsUrl: () => null,
136142 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 }),
137153 pickFolder: async () => null,
138154 confirm: async () => true,
139155 setTheme: async () => undefined,
......@@ -185,6 +201,12 @@ const baseEnvironment = {
185201
186202const baseServerConfig: ServerConfig = {
187203 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 },
188210 cwd: "/tmp/workspace",
189211 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
190212 keybindings: [],
......@@ -232,7 +254,7 @@ describe("wsApi", () => {
232254 rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig);
233255 const { createLocalApi } = await import("./localApi");
234256
235 const api = createLocalApi();
257 const api = createLocalApi(rpcClientMock as never);
236258
237259 await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig);
238260 expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith();
......@@ -395,7 +417,7 @@ describe("wsApi", () => {
395417 rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders });
396418 const { createLocalApi } = await import("./localApi");
397419
398 const api = createLocalApi();
420 const api = createLocalApi(rpcClientMock as never);
399421
400422 await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders });
401423 expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith();
......@@ -409,7 +431,7 @@ describe("wsApi", () => {
409431 rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings);
410432 const { createLocalApi } = await import("./localApi");
411433
412 const api = createLocalApi();
434 const api = createLocalApi(rpcClientMock as never);
413435
414436 await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual(
415437 nextSettings,
......@@ -424,7 +446,7 @@ describe("wsApi", () => {
424446 getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu });
425447
426448 const { createLocalApi } = await import("./localApi");
427 const api = createLocalApi();
449 const api = createLocalApi(rpcClientMock as never);
428450 const items = [{ id: "delete", label: "Delete" }] as const;
429451
430452 await expect(api.contextMenu.show(items)).resolves.toBe("delete");
......@@ -435,7 +457,7 @@ describe("wsApi", () => {
435457 showContextMenuFallbackMock.mockResolvedValue("rename");
436458 const { createLocalApi } = await import("./localApi");
437459
438 const api = createLocalApi();
460 const api = createLocalApi(rpcClientMock as never);
439461 const items = [{ id: "rename", label: "Rename" }] as const;
440462
441463 await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename");
apps/web/src/localApi.ts+14-9
......@@ -1,19 +1,23 @@
11import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";
22
33import { resetGitStatusStateForTests } from "./lib/gitStatusState";
4
5import { __resetWsRpcAtomClientForTests } from "./rpc/client";
64import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
75import { resetServerStateForTests } from "./rpc/serverState";
86import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
9import { getPrimaryWsRpcClientEntry, WsRpcClient, __resetWsRpcClientForTests } from "./wsRpcClient";
7import {
8 resetSavedEnvironmentRegistryStoreForTests,
9 resetSavedEnvironmentRuntimeStoreForTests,
10} from "./environments/runtime";
11import {
12 getPrimaryEnvironmentConnection,
13 resetEnvironmentServiceForTests,
14} from "./environments/runtime";
15import { type WsRpcClient } from "./rpc/wsRpcClient";
1016import { showContextMenuFallback } from "./contextMenuFallback";
1117
1218let cachedApi: LocalApi | undefined;
1319
14export function createLocalApi(
15 rpcClient: WsRpcClient = getPrimaryWsRpcClientEntry().client,
16): LocalApi {
20export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
1721 return {
1822 dialogs: {
1923 pickFolder: async () => {
......@@ -71,7 +75,7 @@ export function readLocalApi(): LocalApi | undefined {
7175 return cachedApi;
7276 }
7377
74 cachedApi = createLocalApi();
78 cachedApi = createLocalApi(getPrimaryEnvironmentConnection().client);
7579 return cachedApi;
7680}
7781
......@@ -85,10 +89,11 @@ export function ensureLocalApi(): LocalApi {
8589
8690export async function __resetLocalApiForTests() {
8791 cachedApi = undefined;
88 await __resetWsRpcAtomClientForTests();
89 await __resetWsRpcClientForTests();
92 await resetEnvironmentServiceForTests();
9093 resetGitStatusStateForTests();
9194 resetRequestLatencyStateForTests();
95 resetSavedEnvironmentRegistryStoreForTests();
96 resetSavedEnvironmentRuntimeStoreForTests();
9297 resetServerStateForTests();
9398 resetWsConnectionStateForTests();
9499}
apps/web/src/observability/clientTracing.ts+2-5
......@@ -2,8 +2,8 @@ import { Exit, Layer, ManagedRuntime, Scope, Tracer } from "effect";
22import { FetchHttpClient, HttpClient } from "effect/unstable/http";
33import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
44
5import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary";
56import { isElectron } from "../env";
6import { resolveServerUrl } from "../lib/utils";
77import { APP_VERSION } from "~/branding";
88
99const DEFAULT_EXPORT_INTERVAL_MS = 1_000;
......@@ -51,10 +51,7 @@ export function configureClientTracing(config: ClientTracingConfig = {}): Promis
5151}
5252
5353async function applyClientTracingConfig(config: ClientTracingConfig): Promise<void> {
54 const otlpTracesUrl = resolveServerUrl({
55 protocol: window.location.protocol === "https:" ? "https" : "http",
56 pathname: "/api/observability/v1/traces",
57 });
54 const otlpTracesUrl = resolvePrimaryEnvironmentHttpUrl("/api/observability/v1/traces");
5855 const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS);
5956 const nextConfigKey = `${otlpTracesUrl}|${exportIntervalMs}`;
6057
apps/web/src/pairingUrl.ts created+33
......@@ -0,0 +1,33 @@
1const PAIRING_TOKEN_PARAM = "token";
2
3function readHashParams(url: URL): URLSearchParams {
4 return new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
5}
6
7export 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
17export 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
28export 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 @@
1010
1111import { Route as rootRouteImport } from './routes/__root'
1212import { Route as SettingsRouteImport } from './routes/settings'
13import { Route as PairRouteImport } from './routes/pair'
1314import { Route as ChatRouteImport } from './routes/_chat'
1415import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1516import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
17import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
1618import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
1719import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
1820import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
......@@ -22,6 +24,11 @@ const SettingsRoute = SettingsRouteImport.update({
2224 path: '/settings',
2325 getParentRoute: () => rootRouteImport,
2426} as any)
27const PairRoute = PairRouteImport.update({
28 id: '/pair',
29 path: '/pair',
30 getParentRoute: () => rootRouteImport,
31} as any)
2532const ChatRoute = ChatRouteImport.update({
2633 id: '/_chat',
2734 getParentRoute: () => rootRouteImport,
......@@ -36,6 +43,11 @@ const SettingsGeneralRoute = SettingsGeneralRouteImport.update({
3643 path: '/general',
3744 getParentRoute: () => SettingsRoute,
3845} as any)
46const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({
47 id: '/connections',
48 path: '/connections',
49 getParentRoute: () => SettingsRoute,
50} as any)
3951const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
4052 id: '/archived',
4153 path: '/archived',
......@@ -55,15 +67,19 @@ const ChatEnvironmentIdThreadIdRoute =
5567
5668export interface FileRoutesByFullPath {
5769 '/': typeof ChatIndexRoute
70 '/pair': typeof PairRoute
5871 '/settings': typeof SettingsRouteWithChildren
5972 '/settings/archived': typeof SettingsArchivedRoute
73 '/settings/connections': typeof SettingsConnectionsRoute
6074 '/settings/general': typeof SettingsGeneralRoute
6175 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
6276 '/draft/$draftId': typeof ChatDraftDraftIdRoute
6377}
6478export interface FileRoutesByTo {
79 '/pair': typeof PairRoute
6580 '/settings': typeof SettingsRouteWithChildren
6681 '/settings/archived': typeof SettingsArchivedRoute
82 '/settings/connections': typeof SettingsConnectionsRoute
6783 '/settings/general': typeof SettingsGeneralRoute
6884 '/': typeof ChatIndexRoute
6985 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
......@@ -72,8 +88,10 @@ export interface FileRoutesByTo {
7288export interface FileRoutesById {
7389 __root__: typeof rootRouteImport
7490 '/_chat': typeof ChatRouteWithChildren
91 '/pair': typeof PairRoute
7592 '/settings': typeof SettingsRouteWithChildren
7693 '/settings/archived': typeof SettingsArchivedRoute
94 '/settings/connections': typeof SettingsConnectionsRoute
7795 '/settings/general': typeof SettingsGeneralRoute
7896 '/_chat/': typeof ChatIndexRoute
7997 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
......@@ -83,15 +101,19 @@ export interface FileRouteTypes {
83101 fileRoutesByFullPath: FileRoutesByFullPath
84102 fullPaths:
85103 | '/'
104 | '/pair'
86105 | '/settings'
87106 | '/settings/archived'
107 | '/settings/connections'
88108 | '/settings/general'
89109 | '/$environmentId/$threadId'
90110 | '/draft/$draftId'
91111 fileRoutesByTo: FileRoutesByTo
92112 to:
113 | '/pair'
93114 | '/settings'
94115 | '/settings/archived'
116 | '/settings/connections'
95117 | '/settings/general'
96118 | '/'
97119 | '/$environmentId/$threadId'
......@@ -99,8 +121,10 @@ export interface FileRouteTypes {
99121 id:
100122 | '__root__'
101123 | '/_chat'
124 | '/pair'
102125 | '/settings'
103126 | '/settings/archived'
127 | '/settings/connections'
104128 | '/settings/general'
105129 | '/_chat/'
106130 | '/_chat/$environmentId/$threadId'
......@@ -109,6 +133,7 @@ export interface FileRouteTypes {
109133}
110134export interface RootRouteChildren {
111135 ChatRoute: typeof ChatRouteWithChildren
136 PairRoute: typeof PairRoute
112137 SettingsRoute: typeof SettingsRouteWithChildren
113138}
114139
......@@ -121,6 +146,13 @@ declare module '@tanstack/react-router' {
121146 preLoaderRoute: typeof SettingsRouteImport
122147 parentRoute: typeof rootRouteImport
123148 }
149 '/pair': {
150 id: '/pair'
151 path: '/pair'
152 fullPath: '/pair'
153 preLoaderRoute: typeof PairRouteImport
154 parentRoute: typeof rootRouteImport
155 }
124156 '/_chat': {
125157 id: '/_chat'
126158 path: ''
......@@ -142,6 +174,13 @@ declare module '@tanstack/react-router' {
142174 preLoaderRoute: typeof SettingsGeneralRouteImport
143175 parentRoute: typeof SettingsRoute
144176 }
177 '/settings/connections': {
178 id: '/settings/connections'
179 path: '/connections'
180 fullPath: '/settings/connections'
181 preLoaderRoute: typeof SettingsConnectionsRouteImport
182 parentRoute: typeof SettingsRoute
183 }
145184 '/settings/archived': {
146185 id: '/settings/archived'
147186 path: '/archived'
......@@ -182,11 +221,13 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
182221
183222interface SettingsRouteChildren {
184223 SettingsArchivedRoute: typeof SettingsArchivedRoute
224 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
185225 SettingsGeneralRoute: typeof SettingsGeneralRoute
186226}
187227
188228const SettingsRouteChildren: SettingsRouteChildren = {
189229 SettingsArchivedRoute: SettingsArchivedRoute,
230 SettingsConnectionsRoute: SettingsConnectionsRoute,
190231 SettingsGeneralRoute: SettingsGeneralRoute,
191232}
192233
......@@ -196,6 +237,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
196237
197238const rootRouteChildren: RootRouteChildren = {
198239 ChatRoute: ChatRouteWithChildren,
240 PairRoute: PairRoute,
199241 SettingsRoute: SettingsRouteWithChildren,
200242}
201243export const routeTree = rootRouteImport
apps/web/src/routes/__root.tsx+70-486
......@@ -1,25 +1,14 @@
1import {
2 type EnvironmentId,
3 OrchestrationEvent,
4 type ServerLifecycleWelcomePayload,
5 ThreadId,
6} from "@t3tools/contracts";
7import {
8 scopedProjectKey,
9 scopedThreadKey,
10 scopeProjectRef,
11 scopeThreadRef,
12} from "@t3tools/client-runtime";
1import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";
133import {
144 Outlet,
155 createRootRouteWithContext,
166 type ErrorComponentProps,
17 useNavigate,
187 useLocation,
8 useNavigate,
199} from "@tanstack/react-router";
20import { useEffect, useEffectEvent, useRef, useState } from "react";
10import { useEffect, useEffectEvent, useRef } from "react";
2111import { QueryClient, useQueryClient } from "@tanstack/react-query";
22import { Throttler } from "@tanstack/react-pacer";
2312
2413import { APP_DISPLAY_NAME } from "../branding";
2514import { AppSidebarLayout } from "../components/AppSidebarLayout";
......@@ -40,38 +29,34 @@ import {
4029 useServerConfigUpdatedSubscription,
4130 useServerWelcomeSubscription,
4231} from "../rpc/serverState";
43import {
44 markPromotedDraftThreadByRef,
45 markPromotedDraftThreadsByRef,
46 useComposerDraftStore,
47} from "../composerDraftStore";
48import {
49 selectProjectsAcrossEnvironments,
50 selectThreadsAcrossEnvironments,
51 useStore,
52} from "../store";
32import { useStore } from "../store";
5333import { useUiStateStore } from "../uiStateStore";
54import { useTerminalStateStore } from "../terminalStateStore";
5534import { migrateLocalSettingsToServer } from "../hooks/useSettings";
56import { providerQueryKeys } from "../lib/providerReactQuery";
57import { projectQueryKeys } from "../lib/projectReactQuery";
58import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
59import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";
60import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";
61import { deriveReplayRetryDecision } from "../orchestrationRecovery";
62import { selectThreadByRef } from "../store";
35import { syncBrowserChromeTheme } from "../hooks/useTheme";
36import {
37 ensureEnvironmentConnectionBootstrapped,
38 getPrimaryEnvironmentConnection,
39 startEnvironmentConnectionService,
40} from "../environments/runtime";
41import { configureClientTracing } from "../observability/clientTracing";
6342import {
64 bindPrimaryWsRpcClientEnvironment,
65 bindWsRpcClientEntryEnvironment,
66 getPrimaryWsRpcClientEntry,
67 listWsRpcClientEntries,
68 subscribeWsRpcClientRegistry,
69 type WsRpcClientEntry,
70} from "~/wsRpcClient";
43 ensurePrimaryEnvironmentReady,
44 resolveInitialServerAuthGateState,
45 updatePrimaryEnvironmentDescriptor,
46} from "../environments/primary";
7147
7248export const Route = createRootRouteWithContext<{
7349 queryClient: QueryClient;
7450}>()({
51 beforeLoad: async () => {
52 const [, authGateState] = await Promise.all([
53 ensurePrimaryEnvironmentReady(),
54 resolveInitialServerAuthGateState(),
55 ]);
56 return {
57 authGateState,
58 };
59 },
7560 component: RootRouteView,
7661 errorComponent: RootRouteErrorView,
7762 head: () => ({
......@@ -80,22 +65,31 @@ export const Route = createRootRouteWithContext<{
8065});
8166
8267function RootRouteView() {
83 if (!readLocalApi()) {
84 return (
85 <div className="flex h-screen flex-col bg-background text-foreground">
86 <div className="flex flex-1 items-center justify-center">
87 <p className="text-sm text-muted-foreground">
88 Connecting to {APP_DISPLAY_NAME} server...
89 </p>
90 </div>
91 </div>
92 );
68 const pathname = useLocation({ select: (location) => location.pathname });
69 const { authGateState } = Route.useRouteContext();
70
71 useEffect(() => {
72 const frame = window.requestAnimationFrame(() => {
73 syncBrowserChromeTheme();
74 });
75 return () => {
76 window.cancelAnimationFrame(frame);
77 };
78 }, [pathname]);
79
80 if (pathname === "/pair") {
81 return <Outlet />;
9382 }
9483
84 if (authGateState.status !== "authenticated") {
85 return <Outlet />;
86 }
9587 return (
9688 <ToastProvider>
9789 <AnchoredToastProvider>
90 <AuthenticatedTracingBootstrap />
9891 <ServerStateBootstrap />
92 <EnvironmentConnectionManagerBootstrap />
9993 <EventRouter />
10094 <WebSocketConnectionCoordinator />
10195 <SlowRpcAckToastCoordinator />
......@@ -180,97 +174,48 @@ function errorDetails(error: unknown): string {
180174 }
181175}
182176
183function coalesceOrchestrationUiEvents(
184 events: ReadonlyArray<OrchestrationEvent>,
185): OrchestrationEvent[] {
186 if (events.length < 2) {
187 return [...events];
188 }
189
190 const coalesced: OrchestrationEvent[] = [];
191 for (const event of events) {
192 const previous = coalesced.at(-1);
193 if (
194 previous?.type === "thread.message-sent" &&
195 event.type === "thread.message-sent" &&
196 previous.payload.threadId === event.payload.threadId &&
197 previous.payload.messageId === event.payload.messageId
198 ) {
199 coalesced[coalesced.length - 1] = {
200 ...event,
201 payload: {
202 ...event.payload,
203 attachments: event.payload.attachments ?? previous.payload.attachments,
204 createdAt: previous.payload.createdAt,
205 text:
206 !event.payload.streaming && event.payload.text.length > 0
207 ? event.payload.text
208 : previous.payload.text + event.payload.text,
209 },
210 };
211 continue;
212 }
213
214 coalesced.push(event);
215 }
177function ServerStateBootstrap() {
178 useEffect(() => startServerStateSync(getPrimaryEnvironmentConnection().client.server), []);
216179
217 return coalesced;
180 return null;
218181}
219182
220const REPLAY_RECOVERY_RETRY_DELAY_MS = 100;
221const MAX_NO_PROGRESS_REPLAY_RETRIES = 3;
222
223function useRegisteredWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> {
224 const [, setRevision] = useState(0);
225
226 useEffect(() => subscribeWsRpcClientRegistry(() => setRevision((value) => value + 1)), []);
183function AuthenticatedTracingBootstrap() {
184 useEffect(() => {
185 void configureClientTracing();
186 }, []);
227187
228 const entries = listWsRpcClientEntries();
229 return entries.length > 0 ? entries : [getPrimaryWsRpcClientEntry()];
188 return null;
230189}
231190
232function ServerStateBootstrap() {
233 useEffect(() => startServerStateSync(getPrimaryWsRpcClientEntry().client.server), []);
191function EnvironmentConnectionManagerBootstrap() {
192 const queryClient = useQueryClient();
193
194 useEffect(() => {
195 return startEnvironmentConnectionService(queryClient);
196 }, [queryClient]);
234197
235198 return null;
236199}
237200
238201function EventRouter() {
239 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);
240202 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();
252203 const navigate = useNavigate();
253204 const pathname = useLocation({ select: (loc) => loc.pathname });
254205 const readPathname = useEffectEvent(() => pathname);
255206 const handledBootstrapThreadIdRef = useRef<string | null>(null);
256207 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
257208 const disposedRef = useRef(false);
258 const bootstrapFromSnapshotRef = useRef<(environmentId: EnvironmentId) => Promise<void>>(
259 async () => undefined,
260 );
261 const schedulePendingDomainEventFlushRef = useRef<() => void>(() => undefined);
262209 const serverConfig = useServerConfig();
263 const clientEntries = useRegisteredWsRpcClientEntries();
264210
265211 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {
266212 if (!payload) return;
267213
268 bindPrimaryWsRpcClientEnvironment(payload.environment.environmentId);
214 updatePrimaryEnvironmentDescriptor(payload.environment);
269215 setActiveEnvironmentId(payload.environment.environmentId);
270 schedulePendingDomainEventFlushRef.current();
271216 migrateLocalSettingsToServer();
272217 void (async () => {
273 await bootstrapFromSnapshotRef.current(payload.environment.environmentId);
218 await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId);
274219 if (disposedRef.current) {
275220 return;
276221 }
......@@ -278,12 +223,14 @@ function EventRouter() {
278223 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
279224 return;
280225 }
281 setProjectExpanded(
282 scopedProjectKey(
283 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
284 ),
285 true,
286 );
226 useUiStateStore
227 .getState()
228 .setProjectExpanded(
229 scopedProjectKey(
230 scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId),
231 ),
232 true,
233 );
287234
288235 if (readPathname() !== "/") {
289236 return;
......@@ -365,379 +312,16 @@ function EventRouter() {
365312 return;
366313 }
367314
368 bindPrimaryWsRpcClientEnvironment(serverConfig.environment.environmentId);
315 updatePrimaryEnvironmentDescriptor(serverConfig.environment);
369316 setActiveEnvironmentId(serverConfig.environment.environmentId);
370 schedulePendingDomainEventFlushRef.current();
371317 }, [serverConfig, setActiveEnvironmentId]);
372318
373319 useEffect(() => {
374 let disposed = false;
375320 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
716321 return () => {
717 disposed = true;
718322 disposedRef.current = true;
719 needsProviderInvalidation = false;
720 schedulePendingDomainEventFlushRef.current = () => undefined;
721 queryInvalidationThrottler.cancel();
722 for (const context of clientContexts) {
723 context.cleanup();
724 }
725323 };
726 }, [
727 applyOrchestrationEvents,
728 clientEntries,
729 navigate,
730 queryClient,
731 removeTerminalState,
732 removeOrphanedTerminalStates,
733 applyTerminalEvent,
734 clearThreadUi,
735 setProjectExpanded,
736 setActiveEnvironmentId,
737 syncProjects,
738 syncServerReadModel,
739 syncThreads,
740 ]);
324 }, []);
741325
742326 useServerWelcomeSubscription(handleWelcome);
743327 useServerConfigUpdatedSubscription(handleServerConfigUpdated);
apps/web/src/routes/_chat.index.tsx+2-26
......@@ -1,33 +1,9 @@
11import { createFileRoute } from "@tanstack/react-router";
22
3import { isElectron } from "../env";
4import { SidebarTrigger } from "../components/ui/sidebar";
3import { NoActiveThreadState } from "../components/NoActiveThreadState";
54
65function ChatIndexRouteView() {
7 return (
8 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">
9 {!isElectron && (
10 <header className="border-b border-border px-3 py-2 md:hidden">
11 <div className="flex items-center gap-2">
12 <SidebarTrigger className="size-7 shrink-0" />
13 <span className="text-sm font-medium text-foreground">Threads</span>
14 </div>
15 </header>
16 )}
17
18 {isElectron && (
19 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
20 <span className="text-xs text-muted-foreground/50">No active thread</span>
21 </div>
22 )}
23
24 <div className="flex flex-1 items-center justify-center">
25 <div className="text-center">
26 <p className="text-sm">Select a thread or create a new one to get started.</p>
27 </div>
28 </div>
29 </div>
30 );
6 return <NoActiveThreadState />;
317}
328
339export const Route = createFileRoute("/_chat/")({
apps/web/src/routes/_chat.tsx+14-1
......@@ -1,7 +1,11 @@
11import { scopeProjectRef } from "@t3tools/client-runtime";
2import { Outlet, createFileRoute } from "@tanstack/react-router";
2import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
33import { useEffect } from "react";
44
5import {
6 ensurePrimaryEnvironmentReady,
7 resolveInitialServerAuthGateState,
8} from "../environments/primary";
59import { useHandleNewThread } from "../hooks/useHandleNewThread";
610import { isTerminalFocused } from "../lib/terminalFocus";
711import { resolveShortcutCommand } from "../keybindings";
......@@ -102,5 +106,14 @@ function ChatRouteLayout() {
102106}
103107
104108export 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 },
105118 component: ChatRouteLayout,
106119});
apps/web/src/routes/pair.tsx created+47
......@@ -0,0 +1,47 @@
1import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
2
3import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface";
4import {
5 ensurePrimaryEnvironmentReady,
6 resolveInitialServerAuthGateState,
7} from "../environments/primary";
8
9export 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
26function 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
45function PairRoutePendingView() {
46 return <PairingPendingSurface />;
47}
apps/web/src/routes/settings.connections.tsx created+7
......@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { ConnectionsSettings } from "../components/settings/ConnectionsSettings";
4
5export 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";
22import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
33import { useEffect, useState } from "react";
44
5import {
6 ensurePrimaryEnvironmentReady,
7 resolveInitialServerAuthGateState,
8} from "../environments/primary";
59import { useSettingsRestore } from "../components/settings/SettingsPanels";
610import { Button } from "../components/ui/button";
711import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";
......@@ -83,7 +87,15 @@ function SettingsRouteLayout() {
8387}
8488
8589export 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
8799 if (location.pathname === "/settings") {
88100 throw redirect({ to: "/settings/general", replace: true });
89101 }
apps/web/src/rpc/client.test.ts deleted-279
......@@ -1,279 +0,0 @@
1import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity";
4
5import {
6 configureClientTracing,
7 __resetClientTracingForTests,
8} from "../observability/clientTracing";
9import { __resetWsRpcAtomClientForTests, runRpc, WsRpcAtomClient } from "./client";
10
11type WsEventType = "open" | "message" | "close" | "error";
12type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
13type WsListener = (event?: WsEvent) => void;
14
15const sockets: MockWebSocket[] = [];
16
17class 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
72const originalWebSocket = globalThis.WebSocket;
73const originalFetch = globalThis.fetch;
74
75function 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
83async 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
98beforeEach(() => {
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
117afterEach(() => {
118 __resetWsRpcAtomClientForTests();
119 globalThis.WebSocket = originalWebSocket;
120 globalThis.fetch = originalFetch;
121 vi.restoreAllMocks();
122});
123
124describe("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 @@
1import { WsRpcGroup } from "@t3tools/contracts";
2import { Effect, Layer, ManagedRuntime } from "effect";
3import { AtomRpc } from "effect/unstable/reactivity";
4
5import {
6 __resetClientTracingForTests,
7 ClientTracingLive,
8 configureClientTracing,
9} from "../observability/clientTracing";
10import { createWsRpcProtocolLayer } from "./protocol";
11
12export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", {
13 group: WsRpcGroup,
14 protocol: Layer.suspend(() => createWsRpcProtocolLayer()),
15}) {}
16
17let sharedRuntime: ManagedRuntime.ManagedRuntime<WsRpcAtomClient, never> | null = null;
18
19function getRuntime() {
20 if (sharedRuntime !== null) {
21 return sharedRuntime;
22 }
23
24 sharedRuntime = ManagedRuntime.make(Layer.mergeAll(WsRpcAtomClient.layer, ClientTracingLive));
25 return sharedRuntime;
26}
27
28export 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
37export 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";
33import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
44import * as Socket from "effect/unstable/socket/Socket";
55
6import { resolveServerUrl } from "../lib/utils";
76import {
87 acknowledgeRpcRequest,
98 clearAllTrackedRpcRequests,
......@@ -18,44 +17,96 @@ import {
1817 WS_RECONNECT_MAX_RETRIES,
1918} from "./wsConnectionState";
2019
21export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
20export 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}
2226
27export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup);
2328type RpcClientFactory = typeof makeWsRpcProtocolClient;
2429export type WsRpcProtocolClient =
2530 RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never;
31export type WsRpcProtocolSocketUrlProvider = string | (() => Promise<string>);
32
33function 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
40function 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
50function 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
65export 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);
2685
27export function createWsRpcProtocolLayer(url?: string) {
28 const resolvedUrl = resolveServerUrl({
29 url,
30 protocol: window.location.protocol === "https:" ? "wss" : "ws",
31 pathname: "/ws",
32 });
3386 const trackingWebSocketConstructorLayer = Layer.succeed(
3487 Socket.WebSocketConstructor,
3588 (socketUrl, protocols) => {
36 recordWsConnectionAttempt(socketUrl);
89 lifecycle.onAttempt(socketUrl);
3790 const socket = new globalThis.WebSocket(socketUrl, protocols);
3891
3992 socket.addEventListener(
4093 "open",
4194 () => {
42 recordWsConnectionOpened();
95 lifecycle.onOpen();
4396 },
4497 { once: true },
4598 );
4699 socket.addEventListener(
47100 "error",
48101 () => {
49 clearAllTrackedRpcRequests();
50 recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.");
102 lifecycle.onError("Unable to connect to the T3 server WebSocket.");
51103 },
52104 { once: true },
53105 );
54106 socket.addEventListener(
55107 "close",
56108 (event) => {
57 clearAllTrackedRpcRequests();
58 recordWsConnectionClosed({
109 lifecycle.onClose({
59110 code: event.code,
60111 reason: event.reason,
61112 });
apps/web/src/rpc/serverState.test.ts+6
......@@ -66,6 +66,12 @@ const baseEnvironment = {
6666
6767const baseServerConfig: ServerConfig = {
6868 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 },
6975 cwd: "/tmp/workspace",
7076 keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json",
7177 keybindings: [],
apps/web/src/rpc/serverState.ts+1-1
......@@ -13,7 +13,7 @@ import {
1313import { Atom } from "effect/unstable/reactivity";
1414import { useCallback, useRef } from "react";
1515
16import type { WsRpcClient } from "../wsRpcClient";
16import type { WsRpcClient } from "./wsRpcClient";
1717import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry";
1818
1919export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"];
apps/web/src/rpc/wsRpcClient.test.ts created+104
......@@ -0,0 +1,104 @@
1import type {
2 GitStatusLocalResult,
3 GitStatusRemoteResult,
4 GitStatusStreamEvent,
5} from "@t3tools/contracts";
6import { describe, expect, it, vi } from "vitest";
7
8vi.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
18import { createWsRpcClient } from "./wsRpcClient";
19import { type WsTransport } from "./wsTransport";
20
21const 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
30const baseRemoteStatus: GitStatusRemoteResult = {
31 hasUpstream: true,
32 aheadCount: 0,
33 behindCount: 0,
34 pr: null,
35};
36
37describe("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 @@
1import {
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";
12import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
13import { Effect, Stream } from "effect";
14
15import { type WsRpcProtocolClient } from "./protocol";
16import { resetWsReconnectBackoff } from "./wsConnectionState";
17import { WsTransport } from "./wsTransport";
18
19type RpcTag = keyof WsRpcProtocolClient & string;
20type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag];
21type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0];
22
23interface StreamSubscriptionOptions {
24 readonly onResubscribe?: () => void;
25}
26
27type 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
32type RpcUnaryNoArgMethod<TTag extends RpcTag> =
33 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
34 ? () => Promise<TSuccess>
35 : never;
36
37type 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
42interface GitRunStackedActionOptions {
43 readonly onProgress?: (event: GitActionProgressEvent) => void;
44}
45
46export 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
113export 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 @@
1import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts";
2import { Stream } from "effect";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
5import {
6 __resetClientTracingForTests,
7 configureClientTracing,
8} from "../observability/clientTracing";
9import {
10 getSlowRpcAckRequests,
11 resetRequestLatencyStateForTests,
12 setSlowRpcAckThresholdMsForTests,
13} from "../rpc/requestLatencyState";
14import {
15 getWsConnectionStatus,
16 getWsConnectionUiState,
17 resetWsConnectionStateForTests,
18} from "../rpc/wsConnectionState";
19import { WsTransport } from "./wsTransport";
20
21type WsEventType = "open" | "message" | "close" | "error";
22type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
23type WsListener = (event?: WsEvent) => void;
24
25const sockets: MockWebSocket[] = [];
26
27class 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
84const originalWebSocket = globalThis.WebSocket;
85const originalFetch = globalThis.fetch;
86
87function 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
95async 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
110beforeEach(() => {
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
136afterEach(async () => {
137 globalThis.WebSocket = originalWebSocket;
138 globalThis.fetch = originalFetch;
139 resetRequestLatencyStateForTests();
140 resetWsConnectionStateForTests();
141 await __resetClientTracingForTests();
142 vi.restoreAllMocks();
143});
144
145describe("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 @@
1import {
2 Cause,
3 Duration,
4 Effect,
5 Exit,
6 Layer,
7 ManagedRuntime,
8 Option,
9 Scope,
10 Stream,
11} from "effect";
12import { RpcClient } from "effect/unstable/rpc";
13
14import { ClientTracingLive } from "../observability/clientTracing";
15import {
16 createWsRpcProtocolLayer,
17 makeWsRpcProtocolClient,
18 type WsProtocolLifecycleHandlers,
19 type WsRpcProtocolClient,
20 type WsRpcProtocolSocketUrlProvider,
21} from "./protocol";
22import { isTransportConnectionErrorMessage } from "./transportError";
23
24interface SubscribeOptions {
25 readonly retryDelay?: Duration.Input;
26 readonly onResubscribe?: () => void;
27}
28
29interface RequestOptions {
30 readonly timeout?: Option.Option<Duration.Input>;
31}
32
33const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250);
34const NOOP: () => void = () => undefined;
35
36interface TransportSession {
37 readonly clientPromise: Promise<WsRpcProtocolClient>;
38 readonly clientScope: Scope.Closeable;
39 readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
40}
41
42function 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
49export 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
272function 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 @@
1import {
2 type EnvironmentId,
3 type MessageId,
4 type OrchestrationCheckpointSummary,
5 type OrchestrationEvent,
6 type OrchestrationMessage,
7 type OrchestrationProposedPlan,
8 type OrchestrationReadModel,
9 type OrchestrationSession,
10 type OrchestrationSessionStatus,
11 type OrchestrationThread,
12 type OrchestrationThreadActivity,
13 type ProjectId,
14 type ProviderKind,
15 type ScopedProjectRef,
16 type ScopedThreadRef,
1import type {
2 EnvironmentId,
3 MessageId,
4 OrchestrationCheckpointSummary,
5 OrchestrationEvent,
6 OrchestrationMessage,
7 OrchestrationProposedPlan,
8 OrchestrationReadModel,
9 OrchestrationSession,
10 OrchestrationSessionStatus,
11 OrchestrationThread,
12 OrchestrationThreadActivity,
13 ProjectId,
14 ProviderKind,
15 ScopedProjectRef,
16 ScopedThreadRef,
1717 ThreadId,
18 type TurnId,
18 TurnId,
1919} from "@t3tools/contracts";
2020import { resolveModelSlugForProvider } from "@t3tools/shared/model";
2121import { create } from "zustand";
......@@ -36,6 +36,7 @@ import {
3636 type ThreadTurnState,
3737 type TurnDiffSummary,
3838} from "./types";
39import { resolveEnvironmentHttpUrl } from "./environments/runtime";
3940import { sanitizeThreadErrorMessage } from "./rpc/transportError";
4041
4142export interface EnvironmentState {
......@@ -136,14 +137,17 @@ function mapSession(session: OrchestrationSession): ThreadSession {
136137 };
137138}
138139
139function mapMessage(message: OrchestrationMessage): ChatMessage {
140function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage): ChatMessage {
140141 const attachments = message.attachments?.map((attachment) => ({
141142 type: "image" as const,
142143 id: attachment.id,
143144 name: attachment.name,
144145 mimeType: attachment.mimeType,
145146 sizeBytes: attachment.sizeBytes,
146 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
147 previewUrl: resolveEnvironmentHttpUrl({
148 environmentId,
149 pathname: attachmentPreviewRoutePath(attachment.id),
150 }),
147151 }));
148152
149153 return {
......@@ -212,7 +216,7 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T
212216 runtimeMode: thread.runtimeMode,
213217 interactionMode: thread.interactionMode,
214218 session: thread.session ? mapSession(thread.session) : null,
215 messages: thread.messages.map(mapMessage),
219 messages: thread.messages.map((message) => mapMessage(environmentId, message)),
216220 proposedPlans: thread.proposedPlans.map(mapProposedPlan),
217221 error: sanitizeThreadErrorMessage(thread.session?.lastError),
218222 createdAt: thread.createdAt,
......@@ -883,34 +887,6 @@ function toLegacyProvider(providerName: string | null): ProviderKind {
883887 return "codex";
884888}
885889
886function 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
907function toAttachmentPreviewUrl(rawUrl: string): string {
908 if (rawUrl.startsWith("/")) {
909 return `${resolveWsHttpOrigin()}${rawUrl}`;
910 }
911 return rawUrl;
912}
913
914890function attachmentPreviewRoutePath(attachmentId: string): string {
915891 return `/attachments/${encodeURIComponent(attachmentId)}`;
916892}
......@@ -1298,7 +1274,7 @@ function applyEnvironmentOrchestrationEvent(
12981274
12991275 case "thread.message-sent":
13001276 return updateThreadState(state, event.payload.threadId, (thread) => {
1301 const message = mapMessage({
1277 const message = mapMessage(thread.environmentId, {
13021278 id: event.payload.messageId,
13031279 role: event.payload.role,
13041280 text: event.payload.text,
......@@ -1631,6 +1607,16 @@ export function selectThreadsAcrossEnvironments(state: AppState): Thread[] {
16311607 );
16321608}
16331609
1610/** Like `selectThreadsAcrossEnvironments` but returns stable `ThreadShell` references from the store (no derived data). */
1611export 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
16341620export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] {
16351621 return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) =>
16361622 environmentState.threadIds.flatMap((threadId) => {
......@@ -1656,6 +1642,15 @@ export function selectSidebarThreadsForProjectRef(
16561642 });
16571643}
16581644
1645export 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
16591654export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean {
16601655 return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete;
16611656}
apps/web/src/timestampFormat.test.ts+86-2
......@@ -1,6 +1,11 @@
1import { describe, expect, it } from "vitest";
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
3import { getTimestampFormatOptions } from "./timestampFormat";
3import {
4 formatElapsedDurationLabel,
5 formatExpiresInLabel,
6 formatRelativeTimeUntilLabel,
7 getTimestampFormatOptions,
8} from "./timestampFormat";
49
510describe("getTimestampFormatOptions", () => {
611 it("omits hour12 when locale formatting is requested", () => {
......@@ -28,3 +33,82 @@ describe("getTimestampFormatOptions", () => {
2833 });
2934 });
3035});
36
37describe("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
64describe("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
93describe("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) {
7171 const relative = formatRelativeTime(isoDate);
7272 return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
7373}
74
75/**
76 * Relative elapsed duration since an ISO instant, without an "ago" suffix.
77 * Useful for labels like "Connected for 3m".
78 */
79export 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 */
100export 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
114export 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 */
123export 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) => ({
405405
406406useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state));
407407
408if (typeof window !== "undefined") {
408if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
409409 window.addEventListener("beforeunload", () => {
410410 debouncedPersistState.flush();
411411 });
apps/web/src/wsRpcClient.test.ts deleted-130
......@@ -1,130 +0,0 @@
1import type {
2 GitStatusLocalResult,
3 GitStatusRemoteResult,
4 GitStatusStreamEvent,
5} from "@t3tools/contracts";
6import { EnvironmentId } from "@t3tools/contracts";
7import { afterEach, describe, expect, it, vi } from "vitest";
8
9vi.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
19import {
20 __resetWsRpcClientForTests,
21 createWsRpcClient,
22 ensureWsRpcClientEntryForKnownEnvironment,
23 readWsRpcClientEntryForEnvironment,
24} from "./wsRpcClient";
25import { type WsTransport } from "./wsTransport";
26
27const 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
36const baseRemoteStatus: GitStatusRemoteResult = {
37 hasUpstream: true,
38 aheadCount: 0,
39 behindCount: 0,
40 pr: null,
41};
42
43describe("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 @@
1import {
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";
13import { getKnownEnvironmentBaseUrl, type KnownEnvironment } from "@t3tools/client-runtime";
14import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
15import { Effect, Stream } from "effect";
16
17import {
18 getPrimaryKnownEnvironment,
19 resolvePrimaryEnvironmentBootstrapUrl,
20} from "./environmentBootstrap";
21import { type WsRpcProtocolClient } from "./rpc/protocol";
22import { resetWsReconnectBackoff } from "./rpc/wsConnectionState";
23import { WsTransport } from "./wsTransport";
24
25type RpcTag = keyof WsRpcProtocolClient & string;
26type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag];
27type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0];
28
29interface StreamSubscriptionOptions {
30 readonly onResubscribe?: () => void;
31}
32
33type 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
38type RpcUnaryNoArgMethod<TTag extends RpcTag> =
39 RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any>
40 ? () => Promise<TSuccess>
41 : never;
42
43type 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
48interface GitRunStackedActionOptions {
49 readonly onProgress?: (event: GitActionProgressEvent) => void;
50}
51
52export 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
118export interface WsRpcClientEntry {
119 readonly key: string;
120 readonly knownEnvironment: KnownEnvironment;
121 readonly client: WsRpcClient;
122 readonly environmentId: EnvironmentId | null;
123}
124
125type MutableWsRpcClientEntry = {
126 key: string;
127 knownEnvironment: KnownEnvironment;
128 client: WsRpcClient;
129 environmentId: EnvironmentId | null;
130};
131
132const wsRpcClientEntriesByKey = new Map<string, MutableWsRpcClientEntry>();
133const wsRpcClientKeyByEnvironmentId = new Map<EnvironmentId, string>();
134const wsRpcClientRegistryListeners = new Set<() => void>();
135
136function emitWsRpcClientRegistryChange() {
137 for (const listener of wsRpcClientRegistryListeners) {
138 listener();
139 }
140}
141
142function toReadonlyEntry(entry: MutableWsRpcClientEntry): WsRpcClientEntry {
143 return entry;
144}
145
146function 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
160export function subscribeWsRpcClientRegistry(listener: () => void): () => void {
161 wsRpcClientRegistryListeners.add(listener);
162 return () => {
163 wsRpcClientRegistryListeners.delete(listener);
164 };
165}
166
167export function listWsRpcClientEntries(): ReadonlyArray<WsRpcClientEntry> {
168 return [...wsRpcClientEntriesByKey.values()].map(toReadonlyEntry);
169}
170
171export 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
188export 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
197export function getWsRpcClient(): WsRpcClient {
198 return getPrimaryWsRpcClientEntry().client;
199}
200
201export 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
233export function bindPrimaryWsRpcClientEnvironment(environmentId: EnvironmentId): void {
234 bindWsRpcClientEntryEnvironment(getPrimaryWsRpcClientEntry().key, environmentId);
235}
236
237export 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
249export 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
257export 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
266export 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 @@
1import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
4import {
5 __resetClientTracingForTests,
6 configureClientTracing,
7} from "./observability/clientTracing";
8import {
9 getSlowRpcAckRequests,
10 resetRequestLatencyStateForTests,
11 setSlowRpcAckThresholdMsForTests,
12} from "./rpc/requestLatencyState";
13import {
14 getWsConnectionStatus,
15 getWsConnectionUiState,
16 resetWsConnectionStateForTests,
17} from "./rpc/wsConnectionState";
18import { WsTransport } from "./wsTransport";
19
20type WsEventType = "open" | "message" | "close" | "error";
21type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string };
22type WsListener = (event?: WsEvent) => void;
23
24const sockets: MockWebSocket[] = [];
25
26class 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
83const originalWebSocket = globalThis.WebSocket;
84const originalFetch = globalThis.fetch;
85
86function 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
94async 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
109beforeEach(() => {
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
135afterEach(async () => {
136 globalThis.WebSocket = originalWebSocket;
137 globalThis.fetch = originalFetch;
138 resetRequestLatencyStateForTests();
139 resetWsConnectionStateForTests();
140 await __resetClientTracingForTests();
141 vi.restoreAllMocks();
142});
143
144describe("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 @@
1import {
2 Cause,
3 Duration,
4 Effect,
5 Exit,
6 Layer,
7 ManagedRuntime,
8 Option,
9 Scope,
10 Stream,
11} from "effect";
12import { RpcClient } from "effect/unstable/rpc";
13
14import { ClientTracingLive, configureClientTracing } from "./observability/clientTracing";
15import {
16 createWsRpcProtocolLayer,
17 makeWsRpcProtocolClient,
18 type WsRpcProtocolClient,
19} from "./rpc/protocol";
20
21interface SubscribeOptions {
22 readonly retryDelay?: Duration.Input;
23 readonly onResubscribe?: () => void;
24}
25
26interface RequestOptions {
27 readonly timeout?: Option.Option<Duration.Input>;
28}
29
30const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250);
31const NOOP: () => void = () => undefined;
32
33interface TransportSession {
34 readonly clientPromise: Promise<WsRpcProtocolClient>;
35 readonly clientScope: Scope.Closeable;
36 readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>;
37}
38
39function 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
46export 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
261function 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 @@
1import {
2 EnvironmentId,
3 type ExecutionEnvironmentDescriptor,
4 type ServerAuthDescriptor,
5} from "@t3tools/contracts";
6import { HttpResponse, http } from "msw";
7
8const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z";
9const 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
22export 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 @@
11import { Effect, Exit, PubSub, Scope, Stream } from "effect";
22import { WS_METHODS, WsRpcGroup } from "@t3tools/contracts";
3import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
3import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc";
44
55type RpcServerInstance = RpcServer.RpcServer<any>;
66
......@@ -113,6 +113,13 @@ export class BrowserWsRpcHarness {
113113 }
114114 const messages = this.parser.decode(rawData);
115115 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 }
116123 await Effect.runPromise(server.write(0, message as never));
117124 }
118125 }
apps/web/vite.config.ts+42-2
......@@ -6,6 +6,8 @@ import { defineConfig } from "vite";
66import pkg from "./package.json" with { type: "json" };
77
88const port = Number(process.env.PORT ?? 5733);
9const host = process.env.HOST?.trim() || "localhost";
10const configuredWsUrl = process.env.VITE_WS_URL?.trim();
911const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
1012
1113const buildSourcemap =
......@@ -15,6 +17,29 @@ const buildSourcemap =
1517 ? "hidden"
1618 : true;
1719
20function 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
41const devProxyTarget = resolveDevProxyTarget(configuredWsUrl);
42
1843export default defineConfig({
1944 plugins: [
2045 tanstackRouter(),
......@@ -34,21 +59,36 @@ export default defineConfig({
3459 },
3560 define: {
3661 // 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 ?? ""),
3863 "import.meta.env.APP_VERSION": JSON.stringify(pkg.version),
3964 },
4065 resolve: {
4166 tsconfigPaths: true,
4267 },
4368 server: {
69 host,
4470 port,
4571 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 : {}),
4686 hmr: {
4787 // Explicit config so Vite's HMR WebSocket connects reliably
4888 // inside Electron's BrowserWindow. Vite 8 uses console.debug for
4989 // connection logs — enable "Verbose" in DevTools to see them.
5090 protocol: "ws",
51 host: "localhost",
91 host,
5292 },
5393 },
5494 build: {
packages/client-runtime/src/knownEnvironment.test.ts+35-6
......@@ -1,7 +1,7 @@
11import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
4import { createKnownEnvironmentFromWsUrl } from "./knownEnvironment";
4import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment";
55import {
66 parseScopedProjectKey,
77 parseScopedThreadKey,
......@@ -13,22 +13,51 @@ import {
1313} from "./scoped";
1414
1515describe("known environment bootstrap helpers", () => {
16 it("creates known environments from explicit ws urls", () => {
16 it("creates known environments from explicit server base urls", () => {
1717 expect(
18 createKnownEnvironmentFromWsUrl({
18 createKnownEnvironment({
1919 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 },
2124 }),
2225 ).toEqual({
2326 id: "ws:Remote environment",
2427 label: "Remote environment",
2528 source: "manual",
2629 target: {
27 type: "ws",
28 wsUrl: "wss://remote.example.com/ws",
30 httpBaseUrl: "https://remote.example.com",
31 wsBaseUrl: "wss://remote.example.com",
2932 },
3033 });
3134 });
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 });
3261});
3362
3463describe("scoped refs", () => {
packages/client-runtime/src/knownEnvironment.ts+25-11
......@@ -1,8 +1,8 @@
1import type { EnvironmentId } from "@t3tools/contracts";
1import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
22
33export interface KnownEnvironmentConnectionTarget {
4 readonly type: "ws";
5 readonly wsUrl: string;
4 readonly httpBaseUrl: string;
5 readonly wsBaseUrl: string;
66}
77
88export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin";
......@@ -15,25 +15,39 @@ export interface KnownEnvironment {
1515 readonly target: KnownEnvironmentConnectionTarget;
1616}
1717
18export function createKnownEnvironmentFromWsUrl(input: {
18export function createKnownEnvironment(input: {
1919 readonly id?: string;
2020 readonly label: string;
2121 readonly source?: KnownEnvironmentSource;
22 readonly wsUrl: string;
22 readonly target: KnownEnvironmentConnectionTarget;
2323}): KnownEnvironment {
2424 return {
2525 id: input.id ?? `ws:${input.label}`,
2626 label: input.label,
2727 source: input.source ?? "manual",
28 target: {
29 type: "ws",
30 wsUrl: input.wsUrl,
31 },
28 target: input.target,
3229 };
3330}
3431
35export function getKnownEnvironmentBaseUrl(
32export function getKnownEnvironmentWsBaseUrl(
33 environment: KnownEnvironment | null | undefined,
34): string | null {
35 return environment?.target.wsBaseUrl ?? null;
36}
37
38export function getKnownEnvironmentHttpBaseUrl(
3639 environment: KnownEnvironment | null | undefined,
3740): string | null {
38 return environment?.target.wsUrl ?? null;
41 return environment?.target.httpBaseUrl ?? null;
42}
43
44export function attachEnvironmentDescriptor(
45 environment: KnownEnvironment,
46 descriptor: ExecutionEnvironmentDescriptor,
47): KnownEnvironment {
48 return {
49 ...environment,
50 environmentId: descriptor.environmentId,
51 label: descriptor.label,
52 };
3953}
packages/contracts/src/auth.ts created+266
......@@ -0,0 +1,266 @@
1import { Schema } from "effect";
2
3import { 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 */
28export const ServerAuthPolicy = Schema.Literals([
29 "desktop-managed-local",
30 "loopback-browser",
31 "remote-reachable",
32 "unsafe-no-auth",
33]);
34export 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 */
49export const ServerAuthBootstrapMethod = Schema.Literals(["desktop-bootstrap", "one-time-token"]);
50export 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 */
66export const ServerAuthSessionMethod = Schema.Literals([
67 "browser-session-cookie",
68 "bearer-session-token",
69]);
70export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;
71
72export const AuthSessionRole = Schema.Literals(["owner", "client"]);
73export 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 */
95export const ServerAuthDescriptor = Schema.Struct({
96 policy: ServerAuthPolicy,
97 bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod),
98 sessionMethods: Schema.Array(ServerAuthSessionMethod),
99 sessionCookieName: TrimmedNonEmptyString,
100});
101export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type;
102
103export const AuthBootstrapInput = Schema.Struct({
104 credential: TrimmedNonEmptyString,
105});
106export type AuthBootstrapInput = typeof AuthBootstrapInput.Type;
107
108export const AuthBootstrapResult = Schema.Struct({
109 authenticated: Schema.Literal(true),
110 role: AuthSessionRole,
111 sessionMethod: ServerAuthSessionMethod,
112 expiresAt: Schema.DateTimeUtc,
113});
114export type AuthBootstrapResult = typeof AuthBootstrapResult.Type;
115
116export 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});
123export type AuthBearerBootstrapResult = typeof AuthBearerBootstrapResult.Type;
124
125export const AuthWebSocketTokenResult = Schema.Struct({
126 token: TrimmedNonEmptyString,
127 expiresAt: Schema.DateTimeUtc,
128});
129export type AuthWebSocketTokenResult = typeof AuthWebSocketTokenResult.Type;
130
131export const AuthPairingCredentialResult = Schema.Struct({
132 id: TrimmedNonEmptyString,
133 credential: TrimmedNonEmptyString,
134 label: Schema.optionalKey(TrimmedNonEmptyString),
135 expiresAt: Schema.DateTimeUtc,
136});
137export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Type;
138
139export 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});
148export type AuthPairingLink = typeof AuthPairingLink.Type;
149
150export const AuthClientMetadataDeviceType = Schema.Literals([
151 "desktop",
152 "mobile",
153 "tablet",
154 "bot",
155 "unknown",
156]);
157export type AuthClientMetadataDeviceType = typeof AuthClientMetadataDeviceType.Type;
158
159export 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});
167export type AuthClientMetadata = typeof AuthClientMetadata.Type;
168
169export 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});
181export type AuthClientSession = typeof AuthClientSession.Type;
182
183export const AuthAccessSnapshot = Schema.Struct({
184 pairingLinks: Schema.Array(AuthPairingLink),
185 clientSessions: Schema.Array(AuthClientSession),
186});
187export type AuthAccessSnapshot = typeof AuthAccessSnapshot.Type;
188
189export const AuthAccessStreamSnapshotEvent = Schema.Struct({
190 version: Schema.Literal(1),
191 revision: Schema.Number,
192 type: Schema.Literal("snapshot"),
193 payload: AuthAccessSnapshot,
194});
195export type AuthAccessStreamSnapshotEvent = typeof AuthAccessStreamSnapshotEvent.Type;
196
197export const AuthAccessStreamPairingLinkUpsertedEvent = Schema.Struct({
198 version: Schema.Literal(1),
199 revision: Schema.Number,
200 type: Schema.Literal("pairingLinkUpserted"),
201 payload: AuthPairingLink,
202});
203export type AuthAccessStreamPairingLinkUpsertedEvent =
204 typeof AuthAccessStreamPairingLinkUpsertedEvent.Type;
205
206export 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});
214export type AuthAccessStreamPairingLinkRemovedEvent =
215 typeof AuthAccessStreamPairingLinkRemovedEvent.Type;
216
217export const AuthAccessStreamClientUpsertedEvent = Schema.Struct({
218 version: Schema.Literal(1),
219 revision: Schema.Number,
220 type: Schema.Literal("clientUpserted"),
221 payload: AuthClientSession,
222});
223export type AuthAccessStreamClientUpsertedEvent = typeof AuthAccessStreamClientUpsertedEvent.Type;
224
225export 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});
233export type AuthAccessStreamClientRemovedEvent = typeof AuthAccessStreamClientRemovedEvent.Type;
234
235export const AuthAccessStreamEvent = Schema.Union([
236 AuthAccessStreamSnapshotEvent,
237 AuthAccessStreamPairingLinkUpsertedEvent,
238 AuthAccessStreamPairingLinkRemovedEvent,
239 AuthAccessStreamClientUpsertedEvent,
240 AuthAccessStreamClientRemovedEvent,
241]);
242export type AuthAccessStreamEvent = typeof AuthAccessStreamEvent.Type;
243
244export const AuthRevokePairingLinkInput = Schema.Struct({
245 id: TrimmedNonEmptyString,
246});
247export type AuthRevokePairingLinkInput = typeof AuthRevokePairingLinkInput.Type;
248
249export const AuthRevokeClientSessionInput = Schema.Struct({
250 sessionId: AuthSessionId,
251});
252export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.Type;
253
254export const AuthCreatePairingCredentialInput = Schema.Struct({
255 label: Schema.optionalKey(TrimmedNonEmptyString),
256});
257export type AuthCreatePairingCredentialInput = typeof AuthCreatePairingCredentialInput.Type;
258
259export 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});
266export type AuthSessionState = typeof AuthSessionState.Type;
packages/contracts/src/baseSchemas.ts+2
......@@ -29,6 +29,8 @@ export const MessageId = makeEntityId("MessageId");
2929export type MessageId = typeof MessageId.Type;
3030export const TurnId = makeEntityId("TurnId");
3131export type TurnId = typeof TurnId.Type;
32export const AuthSessionId = makeEntityId("AuthSessionId");
33export type AuthSessionId = typeof AuthSessionId.Type;
3234
3335export const ProviderItemId = makeEntityId("ProviderItemId");
3436export type ProviderItemId = typeof ProviderItemId.Type;
packages/contracts/src/index.ts+1
......@@ -1,4 +1,5 @@
11export * from "./baseSchemas";
2export * from "./auth";
23export * from "./environment";
34export * from "./ipc";
45export * from "./terminal";
packages/contracts/src/ipc.ts+13-2
......@@ -107,12 +107,23 @@ export interface DesktopUpdateCheckResult {
107107
108108export interface DesktopEnvironmentBootstrap {
109109 label: string;
110 wsUrl: string | null;
110 httpBaseUrl: string | null;
111 wsBaseUrl: string | null;
112 bootstrapToken?: string;
113}
114
115export type DesktopServerExposureMode = "local-only" | "network-accessible";
116
117export interface DesktopServerExposureState {
118 mode: DesktopServerExposureMode;
119 endpointUrl: string | null;
120 advertisedHost: string | null;
111121}
112122
113123export interface DesktopBridge {
114 getWsUrl: () => string | null;
115124 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
125 getServerExposureState: () => Promise<DesktopServerExposureState>;
126 setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>;
116127 pickFolder: () => Promise<string | null>;
117128 confirm: (message: string) => Promise<boolean>;
118129 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,
6666
6767export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = {
6868 codex: {
69 "gpt-5-codex": "gpt-5.4",
6970 "5.4": "gpt-5.4",
7071 "5.3": "gpt-5.3-codex",
7172 "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";
33import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
44
55import { OpenError, OpenInEditorInput } from "./editor";
6import { AuthAccessStreamEvent } from "./auth";
67import {
78 GitActionProgressEvent,
89 GitCheckoutInput,
......@@ -118,6 +119,7 @@ export const WS_METHODS = {
118119 subscribeTerminalEvents: "subscribeTerminalEvents",
119120 subscribeServerConfig: "subscribeServerConfig",
120121 subscribeServerLifecycle: "subscribeServerLifecycle",
122 subscribeAuthAccess: "subscribeAuthAccess",
121123} as const;
122124
123125export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, {
......@@ -334,6 +336,12 @@ export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServer
334336 stream: true,
335337});
336338
339export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, {
340 payload: Schema.Struct({}),
341 success: AuthAccessStreamEvent,
342 stream: true,
343});
344
337345export const WsRpcGroup = RpcGroup.make(
338346 WsServerGetConfigRpc,
339347 WsServerRefreshProvidersRpc,
......@@ -365,6 +373,7 @@ export const WsRpcGroup = RpcGroup.make(
365373 WsSubscribeTerminalEventsRpc,
366374 WsSubscribeServerConfigRpc,
367375 WsSubscribeServerLifecycleRpc,
376 WsSubscribeAuthAccessRpc,
368377 WsOrchestrationGetSnapshotRpc,
369378 WsOrchestrationDispatchCommandRpc,
370379 WsOrchestrationGetTurnDiffRpc,
packages/contracts/src/server.ts+2
......@@ -1,5 +1,6 @@
11import { Schema } from "effect";
22import { ExecutionEnvironmentDescriptor } from "./environment";
3import { ServerAuthDescriptor } from "./auth";
34import {
45 IsoDateTime,
56 NonNegativeInt,
......@@ -85,6 +86,7 @@ export type ServerObservability = typeof ServerObservability.Type;
8586
8687export const ServerConfig = Schema.Struct({
8788 environment: ExecutionEnvironmentDescriptor,
89 auth: ServerAuthDescriptor,
8890 cwd: TrimmedNonEmptyString,
8991 keybindingsConfigPath: TrimmedNonEmptyString,
9092 keybindings: ResolvedKeybindingsConfig,
packages/shared/src/model.test.ts+1
......@@ -48,6 +48,7 @@ const claudeCaps: ModelCapabilities = {
4848
4949describe("normalizeModelSlug", () => {
5050 it("maps known aliases to canonical slugs", () => {
51 expect(normalizeModelSlug("gpt-5-codex")).toBe("gpt-5.4");
5152 expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex");
5253 expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6");
5354 });
scripts/dev-runner.test.ts+5-13
......@@ -54,7 +54,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
5454 serverOffset: 0,
5555 webOffset: 0,
5656 t3Home: undefined,
57 authToken: undefined,
5857 noBrowser: undefined,
5958 autoBootstrapProjectFromCwd: undefined,
6059 logWebSocketEvents: undefined,
......@@ -75,7 +74,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
7574 serverOffset: 0,
7675 webOffset: 0,
7776 t3Home: "/tmp/custom-t3",
78 authToken: "secret",
7977 noBrowser: true,
8078 autoBootstrapProjectFromCwd: false,
8179 logWebSocketEvents: true,
......@@ -105,7 +103,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
105103 serverOffset: 0,
106104 webOffset: 0,
107105 t3Home: undefined,
108 authToken: undefined,
109106 noBrowser: undefined,
110107 autoBootstrapProjectFromCwd: undefined,
111108 logWebSocketEvents: undefined,
......@@ -127,7 +124,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
127124 serverOffset: 0,
128125 webOffset: 0,
129126 t3Home: undefined,
130 authToken: undefined,
131127 noBrowser: undefined,
132128 autoBootstrapProjectFromCwd: undefined,
133129 logWebSocketEvents: false,
......@@ -148,7 +144,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
148144 serverOffset: 0,
149145 webOffset: 0,
150146 t3Home: "/tmp/my-t3",
151 authToken: undefined,
152147 noBrowser: undefined,
153148 autoBootstrapProjectFromCwd: undefined,
154149 logWebSocketEvents: undefined,
......@@ -161,13 +156,12 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
161156 }),
162157 );
163158
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", () =>
165160 Effect.gen(function* () {
166161 const env = yield* createDevRunnerEnv({
167162 mode: "dev:desktop",
168163 baseEnv: {
169164 T3CODE_PORT: "3773",
170 T3CODE_AUTH_TOKEN: "stale-token",
171165 T3CODE_MODE: "web",
172166 T3CODE_NO_BROWSER: "0",
173167 T3CODE_HOST: "0.0.0.0",
......@@ -176,7 +170,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
176170 serverOffset: 0,
177171 webOffset: 0,
178172 t3Home: "/tmp/my-t3",
179 authToken: "fresh-token",
180173 noBrowser: true,
181174 autoBootstrapProjectFromCwd: undefined,
182175 logWebSocketEvents: undefined,
......@@ -187,14 +180,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
187180
188181 assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3"));
189182 assert.equal(env.PORT, "5733");
190 assert.equal(env.ELECTRON_RENDERER_PORT, "5733");
191 assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:5733");
192 assert.equal(env.T3CODE_PORT, undefined);
193 assert.equal(env.T3CODE_AUTH_TOKEN, undefined);
183 assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733");
184 assert.equal(env.HOST, "127.0.0.1");
185 assert.equal(env.T3CODE_PORT, "4222");
194186 assert.equal(env.T3CODE_MODE, undefined);
195187 assert.equal(env.T3CODE_NO_BROWSER, undefined);
196188 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");
198190 }),
199191 );
200192 });
scripts/dev-runner.ts+7-20
......@@ -13,6 +13,7 @@ const BASE_SERVER_PORT = 3773;
1313const BASE_WEB_PORT = 5733;
1414const MAX_HASH_OFFSET = 3000;
1515const MAX_PORT = 65535;
16const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";
1617
1718export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
1819 path.join(homedir(), ".t3"),
......@@ -120,7 +121,6 @@ interface CreateDevRunnerEnvInput {
120121 readonly serverOffset: number;
121122 readonly webOffset: number;
122123 readonly t3Home: string | undefined;
123 readonly authToken: string | undefined;
124124 readonly noBrowser: boolean | undefined;
125125 readonly autoBootstrapProjectFromCwd: boolean | undefined;
126126 readonly logWebSocketEvents: boolean | undefined;
......@@ -135,7 +135,6 @@ export function createDevRunnerEnv({
135135 serverOffset,
136136 webOffset,
137137 t3Home,
138 authToken,
139138 noBrowser,
140139 autoBootstrapProjectFromCwd,
141140 logWebSocketEvents,
......@@ -152,8 +151,9 @@ export function createDevRunnerEnv({
152151 const output: NodeJS.ProcessEnv = {
153152 ...baseEnv,
154153 PORT: String(webPort),
155 ELECTRON_RENDERER_PORT: String(webPort),
156 VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`,
154 VITE_DEV_SERVER_URL:
155 devUrl?.toString() ??
156 `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`,
157157 T3CODE_HOME: resolvedBaseDir,
158158 };
159159
......@@ -161,9 +161,8 @@ export function createDevRunnerEnv({
161161 output.T3CODE_PORT = String(serverPort);
162162 output.VITE_WS_URL = `ws://localhost:${serverPort}`;
163163 } else {
164 delete output.T3CODE_PORT;
165 delete output.VITE_WS_URL;
166 delete output.T3CODE_AUTH_TOKEN;
164 output.T3CODE_PORT = String(serverPort);
165 output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
167166 delete output.T3CODE_MODE;
168167 delete output.T3CODE_NO_BROWSER;
169168 delete output.T3CODE_HOST;
......@@ -173,12 +172,6 @@ export function createDevRunnerEnv({
173172 output.T3CODE_HOST = host;
174173 }
175174
176 if (!isDesktopMode && authToken !== undefined) {
177 output.T3CODE_AUTH_TOKEN = authToken;
178 } else if (!isDesktopMode) {
179 delete output.T3CODE_AUTH_TOKEN;
180 }
181
182175 if (!isDesktopMode && noBrowser !== undefined) {
183176 output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0";
184177 } else if (!isDesktopMode) {
......@@ -208,6 +201,7 @@ export function createDevRunnerEnv({
208201 }
209202
210203 if (isDesktopMode) {
204 output.HOST = DESKTOP_DEV_LOOPBACK_HOST;
211205 delete output.T3CODE_DESKTOP_WS_URL;
212206 }
213207
......@@ -350,7 +344,6 @@ export function resolveModePortOffsets<R = NetService>({
350344interface DevRunnerCliInput {
351345 readonly mode: DevMode;
352346 readonly t3Home: string | undefined;
353 readonly authToken: string | undefined;
354347 readonly noBrowser: boolean | undefined;
355348 readonly autoBootstrapProjectFromCwd: boolean | undefined;
356349 readonly logWebSocketEvents: boolean | undefined;
......@@ -430,7 +423,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
430423 serverOffset,
431424 webOffset,
432425 t3Home: input.t3Home,
433 authToken: input.authToken,
434426 noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser),
435427 autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride(
436428 input.autoBootstrapProjectFromCwd,
......@@ -503,11 +495,6 @@ const devRunnerCli = Command.make("dev-runner", {
503495 Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),
504496 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")),
505497 ),
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 ),
511498 noBrowser: Flag.boolean("no-browser").pipe(
512499 Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."),
513500 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")),
turbo.json+1-1
......@@ -1,10 +1,10 @@
11{
22 "$schema": "https://turbo.build/schema.json",
33 "globalEnv": [
4 "HOST",
45 "PORT",
56 "VITE_WS_URL",
67 "VITE_DEV_SERVER_URL",
7 "ELECTRON_RENDERER_PORT",
88 "T3CODE_LOG_WS_EVENTS",
99 "T3CODE_MODE",
1010 "T3CODE_PORT",