From 886c7342a866f1b3d1e70a7fbc698cea6df13557 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 20:19:48 -0700 Subject: [PATCH 01/16] Implement server auth bootstrap and pairing flow Co-authored-by: codex --- .docs/remote-architecture.md | 302 +++++++ .plans/18-server-auth-model.md | 823 ++++++++++++++++++ apps/desktop/src/main.ts | 68 +- .../Layers/BootstrapCredentialService.test.ts | 60 ++ .../auth/Layers/BootstrapCredentialService.ts | 104 +++ apps/server/src/auth/Layers/ServerAuth.ts | 146 ++++ .../src/auth/Layers/ServerAuthPolicy.ts | 32 + .../src/auth/Layers/ServerSecretStore.test.ts | 77 ++ .../src/auth/Layers/ServerSecretStore.ts | 81 ++ .../Layers/SessionCredentialService.test.ts | 56 ++ .../auth/Layers/SessionCredentialService.ts | 107 +++ .../Services/BootstrapCredentialService.ts | 25 + apps/server/src/auth/Services/ServerAuth.ts | 41 + .../src/auth/Services/ServerAuthPolicy.ts | 11 + .../src/auth/Services/ServerSecretStore.ts | 22 + .../auth/Services/SessionCredentialService.ts | 36 + apps/server/src/auth/http.ts | 54 ++ apps/server/src/auth/tokenCodec.ts | 23 + apps/server/src/cli-config.test.ts | 15 - apps/server/src/cli.ts | 25 +- apps/server/src/config.ts | 6 +- apps/server/src/http.ts | 17 +- apps/server/src/server.test.ts | 254 +++++- apps/server/src/server.ts | 8 + apps/server/src/serverRuntimeStartup.ts | 30 +- apps/server/src/ws.ts | 26 +- apps/web/index.html | 112 ++- apps/web/src/authBootstrap.test.ts | 171 ++++ apps/web/src/authBootstrap.ts | 134 +++ apps/web/src/components/ChatView.browser.tsx | 6 + .../components/KeybindingsToast.browser.tsx | 6 + .../components/auth/PairingRouteSurface.tsx | 172 ++++ .../settings/SettingsPanels.browser.tsx | 6 + apps/web/src/localApi.test.ts | 6 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 42 +- apps/web/src/routes/_chat.tsx | 9 +- apps/web/src/routes/pair.tsx | 36 + apps/web/src/routes/settings.tsx | 8 +- apps/web/src/rpc/serverState.test.ts | 6 + packages/contracts/src/auth.ts | 49 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 1 + packages/contracts/src/server.ts | 2 + scripts/dev-runner.test.ts | 8 - scripts/dev-runner.ts | 16 - 46 files changed, 3107 insertions(+), 154 deletions(-) create mode 100644 .docs/remote-architecture.md create mode 100644 .plans/18-server-auth-model.md create mode 100644 apps/server/src/auth/Layers/BootstrapCredentialService.test.ts create mode 100644 apps/server/src/auth/Layers/BootstrapCredentialService.ts create mode 100644 apps/server/src/auth/Layers/ServerAuth.ts create mode 100644 apps/server/src/auth/Layers/ServerAuthPolicy.ts create mode 100644 apps/server/src/auth/Layers/ServerSecretStore.test.ts create mode 100644 apps/server/src/auth/Layers/ServerSecretStore.ts create mode 100644 apps/server/src/auth/Layers/SessionCredentialService.test.ts create mode 100644 apps/server/src/auth/Layers/SessionCredentialService.ts create mode 100644 apps/server/src/auth/Services/BootstrapCredentialService.ts create mode 100644 apps/server/src/auth/Services/ServerAuth.ts create mode 100644 apps/server/src/auth/Services/ServerAuthPolicy.ts create mode 100644 apps/server/src/auth/Services/ServerSecretStore.ts create mode 100644 apps/server/src/auth/Services/SessionCredentialService.ts create mode 100644 apps/server/src/auth/http.ts create mode 100644 apps/server/src/auth/tokenCodec.ts create mode 100644 apps/web/src/authBootstrap.test.ts create mode 100644 apps/web/src/authBootstrap.ts create mode 100644 apps/web/src/components/auth/PairingRouteSurface.tsx create mode 100644 apps/web/src/routes/pair.tsx create mode 100644 packages/contracts/src/auth.ts diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md new file mode 100644 index 0000000000..32e35d7caf --- /dev/null +++ b/.docs/remote-architecture.md @@ -0,0 +1,302 @@ +# Remote Architecture + +This document describes the target architecture for first-class remote environments in T3 Code. + +It is intentionally architecture-first. It does not define a complete implementation plan or user-facing rollout checklist. The goal is to establish the core model so remote support can be added without another broad rewrite. + +## Goals + +- Treat remote environments as first-class product primitives, not special cases. +- Support multiple ways to reach the same environment. +- Keep the T3 server as the execution boundary. +- Let desktop, mobile, and web all share the same conceptual model. +- Avoid introducing a local control plane unless product pressure proves it is necessary. + +## Non-goals + +- Replacing the existing WebSocket server boundary with a custom transport protocol. +- Making SSH the only remote story. +- Syncing provider auth across machines. +- Shipping every access method in the first iteration. + +## High-level architecture + +T3 already has a clean runtime boundary: the client talks to a T3 server over HTTP/WebSocket, and the server owns orchestration, providers, terminals, git, and filesystem operations. + +Remote support should preserve that boundary. + +```text +┌──────────────────────────────────────────────┐ +│ Client (desktop / mobile / web) │ +│ │ +│ - known environments │ +│ - connection manager │ +│ - environment-aware routing │ +└───────────────┬──────────────────────────────┘ + │ + │ resolves one access endpoint + │ +┌───────────────▼──────────────────────────────┐ +│ Access method │ +│ │ +│ - direct ws / wss │ +│ - tunneled ws / wss │ +│ - desktop-managed ssh bootstrap + forward │ +└───────────────┬──────────────────────────────┘ + │ + │ connects to one T3 server + │ +┌───────────────▼──────────────────────────────┐ +│ Execution environment = one T3 server │ +│ │ +│ - environment identity │ +│ - provider state │ +│ - projects / threads / terminals │ +│ - git / filesystem / process runtime │ +└──────────────────────────────────────────────┘ +``` + +The important decision is that remoteness is expressed at the environment connection layer, not by splitting the T3 runtime itself. + +## Domain model + +### ExecutionEnvironment + +An `ExecutionEnvironment` is one running T3 server instance. + +It is the unit that owns: + +- provider availability and auth state +- model availability +- projects and threads +- terminal processes +- filesystem access +- git operations +- server settings + +It is identified by a stable `environmentId`. + +This is the shared cross-client primitive. Desktop, mobile, and web should all reason about the same concept here. + +### KnownEnvironment + +A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach. + +It is not server-authored. It is local to a device or client profile. + +Examples: + +- a saved LAN URL +- a saved public `wss://` endpoint +- a desktop-managed SSH host entry +- a saved tunneled environment + +A known environment may or may not know the target `environmentId` before first successful connect. + +### AccessEndpoint + +An `AccessEndpoint` is one concrete way to reach a known environment. + +This is the key abstraction that keeps SSH from taking over the model. + +A single environment may have many endpoints: + +- `wss://t3.example.com` +- `ws://10.0.0.25:3773` +- a tunneled relay URL +- a desktop-managed SSH tunnel that resolves to a local forwarded WebSocket URL + +The environment stays the same. Only the access path changes. + +### RepositoryIdentity + +`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. + +It is not used for routing. It is only used for UI grouping and correlation between local and remote clones of the same repository. + +### Workspace / Project + +The current `Project` model remains environment-local. + +That means: + +- a local clone and a remote clone are different projects +- they may share a `RepositoryIdentity` +- threads still bind to one project in one environment + +## Access methods + +Access methods answer one question: + +How does the client speak WebSocket to a T3 server? + +They do not answer: + +- how the server got started +- who manages the server process +- whether the environment is local or remote + +### 1. Direct WebSocket access + +Examples: + +- `ws://10.0.0.15:3773` +- `wss://t3.example.com` + +This is the base model and should be the first-class default. + +Benefits: + +- works for desktop, mobile, and web +- no client-specific process management required +- best fit for hosted or self-managed remote T3 deployments + +### 2. Tunneled WebSocket access + +Examples: + +- public relay URLs +- private network relay URLs +- local tunnel products such as pipenet + +This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay. + +For T3, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment. + +This is especially useful when: + +- the host is behind NAT +- inbound ports are unavailable +- mobile must reach a desktop-hosted environment +- a machine should be reachable without exposing raw LAN or public ports + +### 3. Desktop-managed SSH access + +SSH is an access and launch helper, not a separate environment type. + +The desktop main process can use SSH to: + +- reach a machine +- probe it +- launch or reuse a remote T3 server +- establish a local port forward + +After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port. + +This keeps the renderer transport model consistent with every other access method. + +## Launch methods + +Launch methods answer a different question: + +How does a T3 server come to exist on the target machine? + +Launch and access should stay separate in the design. + +### 1. Pre-existing server + +The simplest launch method is no launch at all. + +The user or operator already runs T3 on the target machine, and the client connects through a direct or tunneled WebSocket endpoint. + +This should be the first remote mode shipped because it validates the environment model with minimal extra machinery. + +### 2. Desktop-managed remote launch over SSH + +This is the main place where Zed is a useful reference. + +Useful ideas to borrow from Zed: + +- remote probing +- platform detection +- session directories with pid/log metadata +- reconnect-friendly launcher behavior +- desktop-owned connection UX + +What should be different in T3: + +- no custom stdio/socket proxy protocol between renderer and remote runtime +- no attempt to make the remote runtime look like an editor transport +- keep the final client-to-server connection as WebSocket + +The recommended T3 flow is: + +1. Desktop connects over SSH. +2. Desktop probes the remote machine and verifies T3 availability. +3. Desktop launches or reuses a remote T3 server. +4. Desktop establishes local port forwarding. +5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. + +### 3. Client-managed local publish + +This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel. + +This is useful for: + +- exposing a desktop-hosted environment to mobile +- temporary remote access without changing router or firewall settings + +This is still a launch concern, not a new environment kind. + +## Why access and launch must stay separate + +These concerns are easy to conflate, but separating them prevents architectural drift. + +Examples: + +- A manually hosted T3 server might be reached through direct `wss`. +- The same server might also be reachable through a tunnel. +- An SSH-managed server might be launched over SSH but then reached through forwarded WebSocket. +- A local desktop server might be published through a tunnel for mobile. + +In all of those cases, the `ExecutionEnvironment` is the same kind of thing. + +Only the launch and access paths differ. + +## Security model + +Remote support must assume that some environments will be reachable over untrusted networks. + +That means: + +- remote-capable environments should require explicit authentication +- tunnel exposure should not rely on obscurity +- client-saved endpoints should carry enough auth metadata to reconnect safely + +T3 already supports a WebSocket auth token on the server. That should become a first-class part of environment access rather than remaining an incidental query parameter convention. + +For publicly reachable environments, authenticated access should be treated as required. + +## Relationship to Zed + +Zed is a useful reference implementation for managed remote launch and reconnect behavior. + +The relevant lessons are: + +- remote bootstrap should be explicit +- reconnect should be first-class +- connection UX belongs in the client shell +- runtime ownership should stay clearly on the remote host + +The important mismatch is transport shape. + +Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime. + +T3 should not copy that part. + +T3 already has the right runtime boundary: + +- one T3 server per environment +- ordinary HTTP/WebSocket between client and environment + +So T3 should borrow Zed's launch discipline, not its transport protocol. + +## Recommended rollout + +1. First-class known environments and access endpoints. +2. Direct `ws` / `wss` remote environments. +3. Authenticated tunnel-backed environments. +4. Desktop-managed SSH launch and forwarding. +5. Multi-environment UI improvements after the base runtime path is proven. + +This ordering keeps the architecture network-first and transport-agnostic while still leaving room for richer managed remote flows. diff --git a/.plans/18-server-auth-model.md b/.plans/18-server-auth-model.md new file mode 100644 index 0000000000..9f8ba8a05d --- /dev/null +++ b/.plans/18-server-auth-model.md @@ -0,0 +1,823 @@ +# Server Auth Model Plan + +## Purpose + +Define the long-term server auth architecture for T3 Code before first-class remote environments ship. + +This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods. + +This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code. + +## Primary goals + +- Make auth server-wide, not WebSocket-only. +- Make insecure exposure hard to do accidentally. +- Preserve zero-login local desktop UX for desktop-managed environments. +- Support browser-native pairing and session auth. +- Leave room for native/mobile credentials later without rewriting the server boundary. +- Keep auth separate from transport and launch method. + +## Non-goals + +- Full multi-user authorization and RBAC. +- OAuth / SSO / enterprise identity. +- Passkeys or biometric UX in v1. +- Syncing auth state across environments. +- Designing the full remote environment product in this document. + +## Core decisions + +### 1. Auth is a server concern + +Every privileged surface of the T3 server must go through the same auth policy engine: + +- HTTP routes +- WebSocket upgrades +- RPC methods reached through WebSocket + +The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product. + +### 2. Pairing and session are different things + +The system should distinguish: + +- bootstrap credentials +- session credentials + +Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated. + +Session credentials are the durable credentials used after pairing. + +Bootstrap should never become the long-lived request credential. + +### 3. Auth and transport are separate + +Auth must not be defined by how the client reached the server. + +Examples: + +- local desktop-managed server +- LAN `ws://` +- public `wss://` +- tunneled `wss://` +- SSH-forwarded `ws://127.0.0.1:` + +All of these should feed into the same auth model. + +### 4. Exposure level changes defaults + +The more exposed an environment is, the narrower the safe default should be. + +Safe default expectations: + +- local desktop-managed: auto-pair allowed +- loopback browser access: explicit bootstrap allowed +- non-loopback bind: auth required +- tunnel/public endpoint: auth required, explicit enablement required + +### 5. Browser and native clients may use different session credentials + +The auth model should support more than one session credential type even if only one ships first. + +Examples: + +- browser session cookie +- native bearer/device token + +This should be represented in the model now, even if browser cookies are the first implementation. + +## Target auth domain + +### Route classes + +Every route or transport entrypoint should be classified as one of: + +1. `public` +2. `bootstrap` +3. `authenticated` + +#### `public` + +Unauthenticated by definition. + +Should be extremely small. Examples: + +- static shell needed to render the pairing/login UI +- favicon/assets required for the pairing screen +- a minimal server health/version endpoint if needed + +#### `bootstrap` + +Used only to exchange a bootstrap credential for a session. + +Examples: + +- Initial bootstrap envelope over file descriptor at startup +- `POST /api/auth/bootstrap` +- `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX + +#### `authenticated` + +Everything that reveals machine state or mutates it. + +Examples: + +- WebSocket upgrade +- orchestration snapshot and events +- terminal open/write/close +- project search and file writes +- git routes +- attachments +- project favicon lookup +- server settings + +The default stance should be: if it touches the machine, it is authenticated. + +## Credential model + +### Bootstrap credentials + +Initial credential types to model: + +- `desktop-bootstrap` +- `one-time-token` + +Possible future credential types: + +- `device-code` +- `passkey-assertion` +- `external-identity` + +#### `desktop-bootstrap` + +Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments. + +Properties: + +- launcher-provided +- short-lived +- one-time or bounded-use +- never shown to the user as a reusable password + +#### `one-time-token` + +Used for explicit browser/mobile pairing flows. + +Properties: + +- short TTL +- one-time use +- safe to embed in a pairing URL fragment +- exchanged for a session credential + +### Session credentials + +Initial credential types to model: + +- `browser-session-cookie` +- `bearer-session-token` + +#### `browser-session-cookie` + +Primary browser credential. + +Properties: + +- signed +- `HttpOnly` +- bounded lifetime +- revocable by server key rotation or session invalidation + +#### `bearer-session-token` + +Reserved for native/mobile or non-browser clients. + +Properties: + +- opaque token, not a bootstrap secret +- long enough lifetime to survive reconnects +- stored in secure client storage when available + +## Auth policy model + +Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics. + +### Policy examples + +#### `DesktopManagedLocalPolicy` + +Default for desktop-managed local server. + +Allowed bootstrap methods: + +- `desktop-bootstrap` + +Allowed session methods: + +- `browser-session-cookie` + +Disabled by default: + +- `one-time-token` +- `bearer-session-token` +- password login +- public pairing + +#### `LoopbackBrowserPolicy` + +Used for browser access on localhost without desktop-managed bootstrap. + +Allowed bootstrap methods: + +- `one-time-token` + +Allowed session methods: + +- `browser-session-cookie` + +#### `RemoteReachablePolicy` + +Used when binding non-loopback or using an explicit remote/tunnel workflow. + +Allowed bootstrap methods: + +- `one-time-token` +- possibly `desktop-bootstrap` when a desktop shell is brokering access + +Allowed session methods: + +- `browser-session-cookie` +- `bearer-session-token` + +#### `UnsafeNoAuthPolicy` + +Should exist only as an explicit escape hatch. + +Requirements: + +- explicit opt-in flag +- loud startup warnings +- never defaulted automatically + +## Effect-native service model + +### `ServerAuth` + +The main auth facade used by HTTP routes and WebSocket upgrade handling. + +Responsibilities: + +- classify requests +- authenticate requests +- authorize bootstrap attempts +- create sessions from bootstrap credentials +- enforce policy by environment mode + +Sketch: + +```ts +export interface ServerAuthShape { + readonly getCapabilities: Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + routeClass: RouteAuthClass, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly exchangeBootstrapCredential: ( + input: BootstrapExchangeInput, + ) => Effect.Effect; +} + +export class ServerAuth extends ServiceMap.Service()( + "t3/ServerAuth", +) {} +``` + +### `BootstrapCredentialService` + +Owns issuance, storage, validation, and consumption of bootstrap credentials. + +Responsibilities: + +- issue desktop bootstrap grants +- issue one-time pairing tokens +- validate TTL and single-use semantics +- consume bootstrap grants atomically + +Sketch: + +```ts +export interface BootstrapCredentialServiceShape { + readonly issueDesktopBootstrap: ( + input: IssueDesktopBootstrapInput, + ) => Effect.Effect; + readonly issueOneTimeToken: ( + input: IssueOneTimeTokenInput, + ) => Effect.Effect; + readonly consume: ( + presented: PresentedBootstrapCredential, + ) => Effect.Effect; +} +``` + +### `SessionCredentialService` + +Owns creation and validation of authenticated sessions. + +Responsibilities: + +- mint cookie sessions +- mint bearer sessions +- validate active session credentials +- revoke sessions if needed later + +Sketch: + +```ts +export interface SessionCredentialServiceShape { + readonly createBrowserSession: ( + input: CreateSessionFromBootstrapInput, + ) => Effect.Effect; + readonly createBearerSession: ( + input: CreateSessionFromBootstrapInput, + ) => Effect.Effect; + readonly authenticateCookie: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateBearer: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; +} +``` + +### `ServerAuthPolicy` + +Pure policy/config service that decides which credential types are allowed. + +Responsibilities: + +- map runtime mode and bind/exposure settings to allowed auth methods +- answer whether a route can be public +- answer whether remote exposure requires auth + +This should stay mostly pure and cheap to test. + +### `ServerSecretStore` + +Owns long-lived server signing keys and secrets. + +Responsibilities: + +- get or create signing key +- rotate signing key +- abstract secure OS-backed storage vs filesystem fallback + +Important: + +- prefer platform secure storage when available +- support hardened filesystem fallback for headless/server-only environments + +### `BrowserSessionCookieCodec` + +Focused utility service for cookie encode/decode/signing behavior. + +This should not own policy. It should only own the cookie format. + +### `AuthRouteGuards` + +Thin helper layer used by routes to enforce auth consistently. + +Responsibilities: + +- require auth for HTTP route handlers +- classify route auth mode +- convert auth failures into `401` / `403` + +This prevents every route from re-implementing the same pattern. + +Integrates with `HttpRouter.middleware` to enforce auth consistently. + +## Suggested layer graph + +```text +ServerSecretStore + ├─> BootstrapCredentialService + ├─> BrowserSessionCookieCodec + └─> SessionCredentialService + +ServerAuthPolicy + ├─> BootstrapCredentialService + ├─> SessionCredentialService + └─> ServerAuth + +ServerAuth + └─> AuthRouteGuards +``` + +Layer naming should follow existing repo style: + +- `ServerSecretStoreLive` +- `BootstrapCredentialServiceLive` +- `SessionCredentialServiceLive` +- `ServerAuthPolicyLive` +- `ServerAuthLive` +- `AuthRouteGuardsLive` + +## High-level implementation examples + +### Example: WebSocket upgrade auth + +Current state: + +- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts) + +Target shape: + +```ts +const websocketUpgradeAuth = HttpMiddleware.make((httpApp) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateWebSocketUpgrade(request); + return yield* httpApp; + }), +); +``` + +Then the `/ws` route becomes: + +```ts +export const websocketRpcRouteLayer = HttpRouter.add( + "GET", + "/ws", + rpcWebSocketHttpEffect.pipe( + websocketUpgradeAuth, + Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), + ), +); +``` + +This keeps the route itself declarative and makes auth compose like normal HTTP middleware. + +### Example: authenticated HTTP route + +For routes like attachments or project favicon: + +```ts +const authenticatedRoute = (routeClass: RouteAuthClass) => + HttpMiddleware.make((httpApp) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateHttpRequest(request, routeClass); + return yield* httpApp; + }), + ); +``` + +Then: + +```ts +export const attachmentsRouteLayer = HttpRouter.add( + "GET", + `${ATTACHMENTS_ROUTE_PREFIX}/*`, + serveAttachment.pipe( + authenticatedRoute(RouteAuthClass.Authenticated), + Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), + ), +); +``` + +### Example: desktop bootstrap exchange + +The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel. + +That grant is then exchanged for a browser cookie session when the renderer loads. + +Sketch: + +```ts +const pairDesktopRenderer = Effect.gen(function* () { + const bootstrapService = yield* BootstrapCredentialService; + const credential = yield* bootstrapService.issueDesktopBootstrap({ + audience: "desktop-renderer", + ttlMs: 30_000, + }); + return credential; +}); +``` + +The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid. + +### Example: one-time pairing URL + +For browser-driven pairing: + +```ts +const createPairingToken = Effect.gen(function* () { + const bootstrapService = yield* BootstrapCredentialService; + return yield* bootstrapService.issueOneTimeToken({ + ttlMs: 5 * 60_000, + audience: "browser", + }); +}); +``` + +The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it. + +## Sequence diagrams + +These flows are meant to anchor the auth model in concrete user journeys. + +The important invariant across all of them is: + +- access method is not the auth method +- launch method is not the auth method +- bootstrap credential is not the session credential + +### Normal desktop user + +This is the default desktop-managed local flow. + +The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie. + +```text +Participants: + DesktopMain = Electron main + SecretStore = secure local secret backend + T3Server = local backend child process + Frontend = desktop renderer + +DesktopMain -> SecretStore : getOrCreate("server-signing-key") +SecretStore --> DesktopMain : signing key available + +DesktopMain -> T3Server : spawn server (--bootstrap-fd ...) +DesktopMain -> T3Server : send desktop bootstrap envelope +note over T3Server : policy = DesktopManagedLocalPolicy +note over T3Server : allowed pairing = desktop-bootstrap only + +Frontend -> DesktopMain : request local bootstrap grant +DesktopMain --> Frontend : short-lived desktop bootstrap grant + +Frontend -> T3Server : POST /api/auth/bootstrap +T3Server -> T3Server : validate desktop bootstrap grant +T3Server -> T3Server : create browser session +T3Server --> Frontend : Set-Cookie: session=... + +Frontend -> T3Server : GET /ws + authenticated cookie +T3Server -> T3Server : validate cookie session +T3Server --> Frontend : websocket accepted +``` + +### `npx t3` user + +This is the standalone local server flow. + +There is no trusted desktop shell here, so pairing should be explicit. + +```text +Participants: + UserShell = npx t3 launcher + T3Server = standalone local server + Browser = browser tab + +UserShell -> T3Server : start server +T3Server -> T3Server : getOrCreate("server-signing-key") +note over T3Server : policy = LoopbackBrowserPolicy + +UserShell -> T3Server : issue one-time pairing token +T3Server --> UserShell : pairing URL or pairing token + +UserShell --> Browser : open /pair?token=... + +Browser -> T3Server : GET /pair?token=... +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create browser session +T3Server --> Browser : Set-Cookie: session=... +T3Server --> Browser : redirect to app + +Browser -> T3Server : GET /ws + authenticated cookie +T3Server --> Browser : websocket accepted +``` + +### Phone user with tunneled host + +This is the explicit remote access flow for a browser on another device. + +The tunnel only provides reachability. It must not imply trust. + +Recommended UX: + +- desktop shows a QR code +- desktop also shows a copyable pairing URL +- if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access + +```text +Participants: + DesktopUser = user at the host machine + DesktopMain = desktop app + Tunnel = tunnel provider + T3Server = T3 server + PhoneBrowser = mobile browser + +DesktopUser -> DesktopMain : enable remote access via tunnel +DesktopMain -> T3Server : switch policy to RemoteReachablePolicy +DesktopMain -> Tunnel : publish local T3 endpoint +Tunnel --> DesktopMain : public https/wss URL + +DesktopMain -> T3Server : issue one-time pairing token +T3Server --> DesktopMain : pairing token +DesktopMain -> DesktopUser : show QR code / shareable URL + +DesktopUser -> PhoneBrowser : scan QR / open URL +PhoneBrowser -> Tunnel : GET https://public-host/pair?token=... +Tunnel -> T3Server : forward request +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create mobile browser session +T3Server --> PhoneBrowser : Set-Cookie: session=... +T3Server --> PhoneBrowser : redirect to app + +PhoneBrowser -> Tunnel : GET /ws + authenticated cookie +Tunnel -> T3Server : forward websocket upgrade +T3Server --> PhoneBrowser : websocket accepted +``` + +### Phone user with private network + +This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale. + +The auth flow should stay the same. + +```text +Participants: + DesktopUser = user at the host machine + T3Server = T3 server + PrivateNet = tailscale / private LAN + PhoneBrowser = mobile browser + +DesktopUser -> T3Server : enable private-network access +T3Server -> T3Server : switch policy to RemoteReachablePolicy +DesktopUser -> T3Server : issue one-time pairing token +T3Server --> DesktopUser : pairing URL / QR + +DesktopUser -> PhoneBrowser : open private-network URL +PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=... +PrivateNet -> T3Server : route request +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create mobile browser session +T3Server --> PhoneBrowser : Set-Cookie: session=... +T3Server --> PhoneBrowser : redirect to app + +PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie +PrivateNet -> T3Server : websocket upgrade +T3Server --> PhoneBrowser : websocket accepted +``` + +### Desktop user adding new SSH hosts + +SSH should be treated as launch and reachability plumbing, not as the long-term auth model. + +The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment. + +```text +Participants: + DesktopUser = local desktop user + DesktopMain = desktop app + SSH = ssh transport/session + RemoteHost = remote machine + RemoteT3 = remote T3 server + Frontend = desktop renderer + +DesktopUser -> DesktopMain : add SSH host +DesktopMain -> SSH : connect to remote host +SSH -> RemoteHost : probe environment / verify t3 availability +DesktopMain -> SSH : run remote launch command +SSH -> RemoteHost : t3 remote launch --json +RemoteHost -> RemoteT3 : start or reuse server +RemoteT3 --> RemoteHost : port + environment metadata +RemoteHost --> SSH : launch result JSON +SSH --> DesktopMain : remote server details + +DesktopMain -> SSH : establish local port forward +SSH --> DesktopMain : localhost:FORWARDED_PORT ready + +note over RemoteT3 : policy = RemoteReachablePolicy +note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here + +Frontend -> DesktopMain : request bootstrap for selected environment +DesktopMain --> Frontend : short-lived bootstrap grant + +Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port +RemoteT3 -> RemoteT3 : validate bootstrap grant +RemoteT3 -> RemoteT3 : create browser session +RemoteT3 --> Frontend : Set-Cookie: session=... + +Frontend -> RemoteT3 : GET /ws + authenticated cookie +RemoteT3 --> Frontend : websocket accepted +``` + +## Storage decisions + +### Server secrets + +Use a `ServerSecretStore` abstraction. + +Preferred order (use a layer for each, resolve on startup): + +1. OS secure storage if available +2. hardened filesystem fallback if not + +The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials. + +### Client credentials + +Client-side credential persistence should prefer secure storage when available: + +- desktop: OS keychain / secure store +- mobile: platform secure storage +- browser: cookie session for browser auth + +This concern should stay in the client shell/runtime layer, not the server auth layer. + +## What to build now + +These are the parts worth building before remote environments ship: + +1. `ServerAuth` service boundary. +2. route classification and route guards. +3. `ServerSecretStore` abstraction. +4. bootstrap vs session credential split. +5. browser session cookie codec as one session method. +6. explicit auth capabilities/config surfaced in contracts. + +Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained. + +## What to add as part of first remote-capable auth + +1. Browser pairing flow using one-time bootstrap token and cookie session. +2. Desktop-managed auto-bootstrap for the local desktop-managed environment. +3. Auth-required defaults for any non-loopback or explicitly published server. +4. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks. + +## What to defer + +- passkeys / WebAuthn +- iCloud Keychain / Face ID-specific UX +- multi-user permissions +- collaboration roles +- OAuth / SSO +- polished session management UI +- complex device approval flows + +These can all sit on top of the same bootstrap/session/service split. + +## Relationship to future remote environments + +Remote access is one reason this auth model matters, but the auth model should not be remote-shaped. + +Keep the design focused on: + +- one T3 server +- one auth policy +- multiple credential types +- multiple future access methods + +That keeps the server auth model stable even as access methods expand later. + +## Recommended implementation order + +### Phase 1 + +- Introduce route auth classes. +- Add `ServerAuth` and `AuthRouteGuards`. +- Move existing `authToken` check behind `ServerAuth`. +- Require auth for all privileged HTTP routes as well as WebSocket. + +### Phase 2 + +- Add `ServerSecretStore` service with platform-specific layer implementations. + - `layerOSXKeychain`, `layer +- Add bootstrap/session split. +- Add browser session cookie support. +- Add one-time bootstrap exchange endpoint. + +### Phase 3 + +- Add desktop bootstrap flow on top of the same services. +- Make desktop-managed local environments default to bootstrap-only pairing. +- Surface auth capabilities in shared contracts and renderer bootstrap. + +### Phase 4 + +- Add non-browser bearer session support if mobile/native needs it. +- Add richer policy modes for remote-reachable environments. + +## Acceptance criteria + +- No privileged HTTP or WebSocket path bypasses auth policy. +- Local desktop-managed flows still avoid a visible login screen. +- Non-loopback or published environments require explicit authenticated pairing by default. +- Bootstrap and session credentials are distinct in code and in behavior. +- Auth logic is centralized in Effect services/layers rather than route-local branching. diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index de327d0ff8..e585eb076b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -92,7 +92,8 @@ type LinuxDesktopNamedApp = Electron.App & { let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; -let backendAuthToken = ""; +let backendBootstrapToken = ""; +let backendHttpUrl = ""; let backendWsUrl = ""; let restartAttempt = 0; let restartTimer: ReturnType | null = null; @@ -144,7 +145,6 @@ function readPersistedBackendObservabilitySettings(): { function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; - delete env.T3CODE_AUTH_TOKEN; delete env.T3CODE_MODE; delete env.T3CODE_NO_BROWSER; delete env.T3CODE_HOST; @@ -199,6 +199,51 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } +function resolveDesktopBackendHost(): string { + if (!isDevelopment) { + return "127.0.0.1"; + } + + const devServerUrl = process.env.VITE_DEV_SERVER_URL; + if (!devServerUrl) { + return "127.0.0.1"; + } + + try { + const hostname = new URL(devServerUrl).hostname.trim(); + if (hostname === "localhost" || hostname === "127.0.0.1") { + return hostname; + } + } catch { + // Fall through to the default loopback host. + } + + return "127.0.0.1"; +} + +async function waitForBackendHttpReady(baseUrl: string): Promise { + const deadline = Date.now() + 10_000; + + for (;;) { + try { + const response = await fetch(`${baseUrl}/api/auth/session`, { + redirect: "manual", + }); + if (response.ok) { + return; + } + } catch { + // Retry until the backend becomes reachable or the deadline expires. + } + + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -1036,7 +1081,8 @@ function startBackend(): void { noBrowser: true, port: backendPort, t3Home: BASE_DIR, - authToken: backendAuthToken, + host: resolveDesktopBackendHost(), + desktopBootstrapToken: backendBootstrapToken, ...(backendObservabilitySettings.otlpTracesUrl ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } : {}), @@ -1178,6 +1224,7 @@ function registerIpcHandlers(): void { event.returnValue = { label: "Local environment", wsUrl: backendWsUrl || null, + bootstrapToken: backendBootstrapToken || undefined, } as const; }); @@ -1418,7 +1465,7 @@ function createWindow(): BrowserWindow { void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); window.webContents.openDevTools({ mode: "detach" }); } else { - void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); + void window.loadURL(backendHttpUrl); } window.on("closed", () => { @@ -1439,21 +1486,24 @@ configureAppIdentity(); async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap start"); + const backendHost = resolveDesktopBackendHost(); backendPort = await Effect.service(NetService).pipe( - Effect.flatMap((net) => net.reserveLoopbackPort()), + Effect.flatMap((net) => net.reserveLoopbackPort(backendHost)), Effect.provide(NetService.layer), Effect.runPromise, ); writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); - backendAuthToken = Crypto.randomBytes(24).toString("hex"); - const baseUrl = `ws://127.0.0.1:${backendPort}`; - backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; - writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); + backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); + backendHttpUrl = `http://${backendHost}:${backendPort}`; + backendWsUrl = `ws://${backendHost}:${backendPort}`; + writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); startBackend(); writeDesktopLogHeader("bootstrap backend start requested"); + await waitForBackendHttpReady(backendHttpUrl); + writeDesktopLogHeader("bootstrap backend ready"); mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); } diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts new file mode 100644 index 0000000000..5e575fe228 --- /dev/null +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts @@ -0,0 +1,60 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; + +const makeServerConfigLayer = ( + overrides?: Partial>, +) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), + ); + +const makeBootstrapCredentialLayer = ( + overrides?: Partial>, +) => BootstrapCredentialServiceLive.pipe(Layer.provide(makeServerConfigLayer(overrides))); + +it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { + it.effect("issues one-time bootstrap tokens that can only be consumed once", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const token = yield* bootstrapCredentials.issueOneTimeToken(); + const first = yield* bootstrapCredentials.consume(token); + const second = yield* Effect.flip(bootstrapCredentials.consume(token)); + + expect(first.method).toBe("one-time-token"); + expect(second._tag).toBe("BootstrapCredentialError"); + expect(second.message).toContain("Unknown bootstrap credential"); + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); + + it.effect("seeds the desktop bootstrap credential as a one-time grant", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); + const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); + + expect(first.method).toBe("desktop-bootstrap"); + expect(second._tag).toBe("BootstrapCredentialError"); + }).pipe( + Effect.provide( + makeBootstrapCredentialLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); +}); diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts new file mode 100644 index 0000000000..240503dcdd --- /dev/null +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -0,0 +1,104 @@ +import { Effect, Layer, Ref, DateTime, Duration } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { + BootstrapCredentialError, + BootstrapCredentialService, + type BootstrapCredentialServiceShape, + type BootstrapGrant, +} from "../Services/BootstrapCredentialService.ts"; + +interface StoredBootstrapGrant extends BootstrapGrant { + readonly remainingUses: number | "unbounded"; +} + +const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); + +export const makeBootstrapCredentialService = Effect.gen(function* () { + const config = yield* ServerConfig; + const grantsRef = yield* Ref.make(new Map()); + + const seedGrant = (credential: string, grant: StoredBootstrapGrant) => + Ref.update(grantsRef, (current) => { + const next = new Map(current); + next.set(credential, grant); + return next; + }); + + if (config.desktopBootstrapToken) { + const now = yield* DateTime.now; + yield* seedGrant(config.desktopBootstrapToken, { + method: "desktop-bootstrap", + expiresAt: DateTime.add(now, { + milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES), + }), + remainingUses: 1, + }); + } + + const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) => + Effect.gen(function* () { + const credential = crypto.randomUUID(); + const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const now = yield* DateTime.now; + yield* seedGrant(credential, { + method: "one-time-token", + expiresAt: DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }), + remainingUses: 1, + }); + return credential; + }); + + const consume: BootstrapCredentialServiceShape["consume"] = (credential) => + Effect.gen(function* () { + const current = yield* Ref.get(grantsRef); + const grant = current.get(credential); + if (!grant) { + return yield* new BootstrapCredentialError({ + message: "Unknown bootstrap credential.", + }); + } + + if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, grant.expiresAt)) { + yield* Ref.update(grantsRef, (state) => { + const next = new Map(state); + next.delete(credential); + return next; + }); + return yield* new BootstrapCredentialError({ + message: "Bootstrap credential expired.", + }); + } + + const remainingUses = grant.remainingUses; + if (typeof remainingUses === "number") { + yield* Ref.update(grantsRef, (state) => { + const next = new Map(state); + if (remainingUses <= 1) { + next.delete(credential); + } else { + next.set(credential, { + ...grant, + remainingUses: remainingUses - 1, + }); + } + return next; + }); + } + + return { + method: grant.method, + expiresAt: grant.expiresAt, + } satisfies BootstrapGrant; + }); + + return { + issueOneTimeToken, + consume, + } satisfies BootstrapCredentialServiceShape; +}); + +export const BootstrapCredentialServiceLive = Layer.effect( + BootstrapCredentialService, + makeBootstrapCredentialService, +); diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts new file mode 100644 index 0000000000..9e5fefbfb4 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -0,0 +1,146 @@ +import { type AuthBootstrapResult, type AuthSessionState } from "@t3tools/contracts"; +import { DateTime, Effect, Layer, Option } from "effect"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import { HttpServerRequest as HttpServerRequestModule } from "effect/unstable/http"; + +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; +import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; +import { + ServerAuth, + type AuthenticatedSession, + AuthError, + type ServerAuthShape, +} from "../Services/ServerAuth.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; + +const AUTHORIZATION_PREFIX = "Bearer "; + +function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { + const header = request.headers["authorization"]; + if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { + return null; + } + const token = header.slice(AUTHORIZATION_PREFIX.length).trim(); + return token.length > 0 ? token : null; +} + +function parseQueryToken(request: HttpServerRequest.HttpServerRequest): string | null { + const url = HttpServerRequestModule.toURL(request); + if (Option.isNone(url)) { + return null; + } + const token = url.value.searchParams.get("token"); + return token && token.length > 0 ? token : null; +} + +export const makeServerAuth = Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const bootstrapCredentials = yield* BootstrapCredentialService; + const sessions = yield* SessionCredentialService; + const descriptor = yield* policy.getDescriptor(); + + const authenticateToken = (token: string): Effect.Effect => + sessions.verify(token).pipe( + Effect.map((session) => ({ + subject: session.subject, + method: session.method, + ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), + })), + Effect.mapError( + (cause) => + new AuthError({ + message: "Unauthorized request.", + cause, + }), + ), + ); + + const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { + const cookieToken = request.cookies[sessions.cookieName]; + const bearerToken = parseBearerToken(request); + const queryToken = parseQueryToken(request); + const credential = cookieToken ?? bearerToken ?? queryToken; + if (!credential) { + return Effect.fail( + new AuthError({ + message: "Authentication required.", + }), + ); + } + return authenticateToken(credential); + }; + + const getSessionState: ServerAuthShape["getSessionState"] = (request) => + authenticateRequest(request).pipe( + Effect.map( + (session) => + ({ + authenticated: true, + auth: descriptor, + sessionMethod: session.method, + ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), + }) satisfies AuthSessionState, + ), + Effect.catchTag("AuthError", () => + Effect.succeed({ + authenticated: false, + auth: descriptor, + } satisfies AuthSessionState), + ), + ); + + const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = ( + credential, + ) => + bootstrapCredentials.consume(credential).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid bootstrap credential.", + cause, + }), + ), + Effect.flatMap((grant) => + sessions.issue({ + method: "browser-session-cookie", + subject: grant.method, + }), + ), + Effect.map( + (session) => + ({ + authenticated: true, + sessionMethod: session.method, + sessionToken: session.token, + expiresAt: DateTime.toUtc(session.expiresAt), + }) satisfies AuthBootstrapResult, + ), + ); + + const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) => + bootstrapCredentials.issueOneTimeToken().pipe( + Effect.map((credential) => { + const url = new URL(baseUrl); + url.searchParams.set("token", credential); + return url.toString(); + }), + ); + + return { + getDescriptor: () => Effect.succeed(descriptor), + getSessionState, + exchangeBootstrapCredential, + authenticateHttpRequest: authenticateRequest, + authenticateWebSocketUpgrade: authenticateRequest, + issueStartupPairingUrl, + } satisfies ServerAuthShape; +}); + +export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe( + Layer.provideMerge(ServerAuthPolicyLive), + Layer.provideMerge(BootstrapCredentialServiceLive), + Layer.provideMerge(SessionCredentialServiceLive), +); diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts new file mode 100644 index 0000000000..bb718ae4d1 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -0,0 +1,32 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; + +const SESSION_COOKIE_NAME = "t3_session"; + +export const makeServerAuthPolicy = Effect.gen(function* () { + const config = yield* ServerConfig; + + const descriptor: ServerAuthDescriptor = + config.mode === "desktop" + ? { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: SESSION_COOKIE_NAME, + } + : { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: SESSION_COOKIE_NAME, + }; + + return { + getDescriptor: () => Effect.succeed(descriptor), + } satisfies ServerAuthPolicyShape; +}); + +export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.test.ts b/apps/server/src/auth/Layers/ServerSecretStore.test.ts new file mode 100644 index 0000000000..258d03bec7 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerSecretStore.test.ts @@ -0,0 +1,77 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +import { ServerConfig } from "../../config.ts"; +import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; + +const makeServerConfigLayer = () => + ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); + +const makeServerSecretStoreLayer = () => + ServerSecretStoreLive.pipe(Layer.provide(makeServerConfigLayer())); + +const PermissionDeniedFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + readFile: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: path, + description: "Permission denied while reading secret file.", + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makePermissionDeniedSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provide(PermissionDeniedFileSystemLayer), + ); + +it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { + it.effect("returns null when a secret file does not exist", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const secret = yield* secretStore.get("missing-secret"); + + expect(secret).toBeNull(); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("reuses an existing secret instead of regenerating it", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32); + const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32); + + expect(Array.from(second)).toEqual(Array.from(first)); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("propagates read failures other than missing-file errors", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to read secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), + ); +}); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts new file mode 100644 index 0000000000..4c5b5a16ad --- /dev/null +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -0,0 +1,81 @@ +import * as Crypto from "node:crypto"; + +import { Effect, FileSystem, Layer, Path } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +import { ServerConfig } from "../../config.ts"; +import { + SecretStoreError, + ServerSecretStore, + type ServerSecretStoreShape, +} from "../Services/ServerSecretStore.ts"; + +export const makeServerSecretStore = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + + yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); + + const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); + + const isMissingSecretFileError = (cause: unknown): cause is PlatformError.PlatformError => + cause instanceof PlatformError.PlatformError && cause.reason._tag === "NotFound"; + + const get: ServerSecretStoreShape["get"] = (name) => + fileSystem.readFile(resolveSecretPath(name)).pipe( + Effect.map((bytes) => Uint8Array.from(bytes)), + Effect.catch((cause) => + isMissingSecretFileError(cause) + ? Effect.succeed(null) + : Effect.fail( + new SecretStoreError({ + message: `Failed to read secret ${name}.`, + cause, + }), + ), + ), + ); + + const set: ServerSecretStoreShape["set"] = (name, value) => { + const secretPath = resolveSecretPath(name); + const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; + return Effect.gen(function* () { + yield* fileSystem.writeFile(tempPath, value); + yield* fileSystem.rename(tempPath, secretPath); + }).pipe( + Effect.catch((cause) => + fileSystem.remove(tempPath).pipe( + Effect.orElseSucceed(() => undefined), + Effect.map( + () => new SecretStoreError({ message: `Failed to persist secret ${name}.`, cause }), + ), + ), + ), + ); + }; + + const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => + get(name).pipe( + Effect.flatMap((existing) => { + if (existing) { + return Effect.succeed(existing); + } + + const generated = Crypto.randomBytes(bytes); + return set(name, generated).pipe(Effect.as(Uint8Array.from(generated))); + }), + ); + + const remove: ServerSecretStoreShape["remove"] = (name) => + fileSystem.remove(resolveSecretPath(name)).pipe(Effect.orElseSucceed(() => undefined)); + + return { + get, + set, + getOrCreateRandom, + remove, + } satisfies ServerSecretStoreShape; +}); + +export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.test.ts b/apps/server/src/auth/Layers/SessionCredentialService.test.ts new file mode 100644 index 0000000000..5c943d81ec --- /dev/null +++ b/apps/server/src/auth/Layers/SessionCredentialService.test.ts @@ -0,0 +1,56 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; + +const makeServerConfigLayer = ( + overrides?: Partial>, +) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); + +const makeSessionCredentialLayer = ( + overrides?: Partial>, +) => + SessionCredentialServiceLive.pipe( + Layer.provide(ServerSecretStoreLive), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { + it.effect("issues and verifies signed browser session tokens", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + subject: "desktop-bootstrap", + }); + const verified = yield* sessions.verify(issued.token); + + expect(verified.method).toBe("browser-session-cookie"); + expect(verified.subject).toBe("desktop-bootstrap"); + expect(verified.expiresAt).toBe(issued.expiresAt); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); + it.effect("rejects malformed session tokens", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const error = yield* Effect.flip(sessions.verify("not-a-session-token")); + + expect(error._tag).toBe("SessionCredentialError"); + expect(error.message).toContain("Malformed session token"); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); +}); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts new file mode 100644 index 0000000000..51484615b9 --- /dev/null +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -0,0 +1,107 @@ +import { DateTime, Duration, Effect, Layer, Schema } from "effect"; + +import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; +import { + SessionCredentialError, + SessionCredentialService, + type IssuedSession, + type SessionCredentialServiceShape, + type VerifiedSession, +} from "../Services/SessionCredentialService.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../tokenCodec.ts"; + +const SIGNING_SECRET_NAME = "server-signing-key"; +const DEFAULT_SESSION_TTL = Duration.days(30); + +const SessionClaims = Schema.Struct({ + v: Schema.Literal(1), + kind: Schema.Literal("session"), + sub: Schema.String, + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + iat: Schema.Number, + exp: Schema.Number, +}); +type SessionClaims = typeof SessionClaims.Type; + +export const makeSessionCredentialService = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); + + const issue: SessionCredentialServiceShape["issue"] = Effect.fn("issue")(function* (input) { + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), + }); + const claims: SessionClaims = { + v: 1, + kind: "session", + sub: input?.subject ?? "browser", + method: input?.method ?? "browser-session-cookie", + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = base64UrlEncode(JSON.stringify(claims)); + const signature = signPayload(encodedPayload, signingSecret); + + return { + token: `${encodedPayload}.${signature}`, + method: claims.method, + expiresAt: expiresAt, + } satisfies IssuedSession; + }); + + const verify: SessionCredentialServiceShape["verify"] = Effect.fn("verify")(function* (token) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new SessionCredentialError({ + message: "Malformed session token.", + }); + } + + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new SessionCredentialError({ + message: "Invalid session token signature.", + }); + } + + const claims = yield* Effect.try({ + try: () => + Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))), + catch: (cause) => + new SessionCredentialError({ + message: "Invalid session token payload.", + cause, + }), + }); + + if (claims.exp <= Date.now()) { + return yield* new SessionCredentialError({ + message: "Session token expired.", + }); + } + + return { + token, + method: claims.method, + expiresAt: DateTime.makeUnsafe(claims.exp), + subject: claims.sub, + } satisfies VerifiedSession; + }); + + return { + cookieName: "t3_session", + issue, + verify, + } satisfies SessionCredentialServiceShape; +}); + +export const SessionCredentialServiceLive = Layer.effect( + SessionCredentialService, + makeSessionCredentialService, +); diff --git a/apps/server/src/auth/Services/BootstrapCredentialService.ts b/apps/server/src/auth/Services/BootstrapCredentialService.ts new file mode 100644 index 0000000000..dd4083a77a --- /dev/null +++ b/apps/server/src/auth/Services/BootstrapCredentialService.ts @@ -0,0 +1,25 @@ +import type { ServerAuthBootstrapMethod } from "@t3tools/contracts"; +import { Data, DateTime, Duration, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface BootstrapGrant { + readonly method: ServerAuthBootstrapMethod; + readonly expiresAt: DateTime.DateTime; +} + +export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface BootstrapCredentialServiceShape { + readonly issueOneTimeToken: (input?: { + readonly ttl?: Duration.Duration; + }) => Effect.Effect; + readonly consume: (credential: string) => Effect.Effect; +} + +export class BootstrapCredentialService extends ServiceMap.Service< + BootstrapCredentialService, + BootstrapCredentialServiceShape +>()("t3/auth/Services/BootstrapCredentialService") {} diff --git a/apps/server/src/auth/Services/ServerAuth.ts b/apps/server/src/auth/Services/ServerAuth.ts new file mode 100644 index 0000000000..005d7b9f9f --- /dev/null +++ b/apps/server/src/auth/Services/ServerAuth.ts @@ -0,0 +1,41 @@ +import type { + AuthBootstrapResult, + AuthSessionState, + ServerAuthDescriptor, + ServerAuthSessionMethod, +} from "@t3tools/contracts"; +import { Data, DateTime, ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; + +export interface AuthenticatedSession { + readonly subject: string; + readonly method: ServerAuthSessionMethod; + readonly expiresAt?: DateTime.DateTime; +} + +export class AuthError extends Data.TaggedError("AuthError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface ServerAuthShape { + readonly getDescriptor: () => Effect.Effect; + readonly getSessionState: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly exchangeBootstrapCredential: ( + credential: string, + ) => Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect; +} + +export class ServerAuth extends ServiceMap.Service()( + "t3/auth/Services/ServerAuth", +) {} diff --git a/apps/server/src/auth/Services/ServerAuthPolicy.ts b/apps/server/src/auth/Services/ServerAuthPolicy.ts new file mode 100644 index 0000000000..43dae6ca69 --- /dev/null +++ b/apps/server/src/auth/Services/ServerAuthPolicy.ts @@ -0,0 +1,11 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ServerAuthPolicyShape { + readonly getDescriptor: () => Effect.Effect; +} + +export class ServerAuthPolicy extends ServiceMap.Service()( + "t3/auth/Services/ServerAuthPolicy", +) {} diff --git a/apps/server/src/auth/Services/ServerSecretStore.ts b/apps/server/src/auth/Services/ServerSecretStore.ts new file mode 100644 index 0000000000..376527aea3 --- /dev/null +++ b/apps/server/src/auth/Services/ServerSecretStore.ts @@ -0,0 +1,22 @@ +import { Data, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface ServerSecretStoreShape { + readonly get: (name: string) => Effect.Effect; + readonly set: (name: string, value: Uint8Array) => Effect.Effect; + readonly getOrCreateRandom: ( + name: string, + bytes: number, + ) => Effect.Effect; + readonly remove: (name: string) => Effect.Effect; +} + +export class ServerSecretStore extends ServiceMap.Service< + ServerSecretStore, + ServerSecretStoreShape +>()("t3/auth/Services/ServerSecretStore") {} diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts new file mode 100644 index 0000000000..dabf03c816 --- /dev/null +++ b/apps/server/src/auth/Services/SessionCredentialService.ts @@ -0,0 +1,36 @@ +import type { ServerAuthSessionMethod } from "@t3tools/contracts"; +import { Data, DateTime, Duration, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface IssuedSession { + readonly token: string; + readonly method: ServerAuthSessionMethod; + readonly expiresAt: DateTime.DateTime; +} + +export interface VerifiedSession { + readonly token: string; + readonly method: ServerAuthSessionMethod; + readonly expiresAt?: DateTime.DateTime; + readonly subject: string; +} + +export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface SessionCredentialServiceShape { + readonly cookieName: string; + readonly issue: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly method?: ServerAuthSessionMethod; + }) => Effect.Effect; + readonly verify: (token: string) => Effect.Effect; +} + +export class SessionCredentialService extends ServiceMap.Service< + SessionCredentialService, + SessionCredentialServiceShape +>()("t3/auth/Services/SessionCredentialService") {} diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts new file mode 100644 index 0000000000..8bb1ab93be --- /dev/null +++ b/apps/server/src/auth/http.ts @@ -0,0 +1,54 @@ +import { AuthBootstrapInput } from "@t3tools/contracts"; +import { DateTime, Effect, Schema } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; + +export const toUnauthorizedResponse = (error: AuthError) => + HttpServerResponse.jsonUnsafe( + { + error: error.message, + }, + { status: 401 }, + ); + +export const authSessionRouteLayer = HttpRouter.add( + "GET", + "/api/auth/session", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.getSessionState(request); + return HttpServerResponse.jsonUnsafe(session, { status: 200 }); + }), +); + +export const authBootstrapRouteLayer = HttpRouter.add( + "POST", + "/api/auth/bootstrap", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const descriptor = yield* serverAuth.getDescriptor(); + const payload = yield* request.json.pipe( + Effect.flatMap((body) => Schema.decodeUnknownEffect(AuthBootstrapInput)(body)), + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid bootstrap payload.", + cause, + }), + ), + ); + const result = yield* serverAuth.exchangeBootstrapCredential(payload.credential); + + return yield* HttpServerResponse.jsonUnsafe(result, { status: 200 }).pipe( + HttpServerResponse.setCookie(descriptor.sessionCookieName, result.sessionToken, { + expires: DateTime.toDate(result.expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ); + }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), +); diff --git a/apps/server/src/auth/tokenCodec.ts b/apps/server/src/auth/tokenCodec.ts new file mode 100644 index 0000000000..9345f334b0 --- /dev/null +++ b/apps/server/src/auth/tokenCodec.ts @@ -0,0 +1,23 @@ +import * as Crypto from "node:crypto"; + +export function base64UrlEncode(input: string | Uint8Array): string { + const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); + return buffer.toString("base64url"); +} + +export function base64UrlDecodeUtf8(input: string): string { + return Buffer.from(input, "base64url").toString("utf8"); +} + +export function signPayload(payload: string, secret: Uint8Array): string { + return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); +} + +export function timingSafeEqualBase64Url(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "base64url"); + const rightBuffer = Buffer.from(right, "base64url"); + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return Crypto.timingSafeEqual(leftBuffer, rightBuffer); +} diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index ef2f9f55d8..63b3974658 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -43,7 +43,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOME: baseDir, VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "true", - T3CODE_AUTH_TOKEN: "env-token", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "true", }, @@ -85,7 +83,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, - authToken: "env-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -106,7 +103,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.some(true), - authToken: Option.some("flag-token"), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), @@ -125,7 +121,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOME: join(os.tmpdir(), "ignored-base"), VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "false", - T3CODE_AUTH_TOKEN: "ignored-token", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "false", }, @@ -148,7 +143,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, - authToken: "flag-token", autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -166,7 +160,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { t3Home: baseDir, devUrl: "http://127.0.0.1:5173", noBrowser: true, - authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, otlpTracesUrl: "http://localhost:4318/v1/traces", @@ -183,7 +176,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -218,7 +210,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, - authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -242,7 +233,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.some(customCwd), devUrl: Option.some(new URL("http://127.0.0.1:5173")), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -285,7 +275,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { t3Home: "/tmp/t3-bootstrap-home", devUrl: "http://127.0.0.1:5173", noBrowser: false, - authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, }); @@ -300,7 +289,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.none(), - authToken: Option.some("flag-token"), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -338,7 +326,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, - authToken: "flag-token", autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -371,7 +358,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -402,7 +388,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: resolved.staticDir, devUrl: undefined, noBrowser: true, - authToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 9ece02a0d3..7d29e99c17 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -25,7 +25,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ t3Home: Schema.optional(Schema.String), devUrl: Schema.optional(Schema.URLFromString), noBrowser: Schema.optional(Schema.Boolean), - authToken: Schema.optional(Schema.String), + desktopBootstrapToken: Schema.optional(Schema.String), autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), logWebSocketEvents: Schema.optional(Schema.Boolean), otlpTracesUrl: Schema.optional(Schema.String), @@ -58,11 +58,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe( Flag.withDescription("Disable automatic browser opening."), Flag.optional, ); -const authTokenFlag = Flag.string("auth-token").pipe( - Flag.withDescription("Auth token required for WebSocket connections."), - Flag.withAlias("token"), - Flag.optional, -); const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( Flag.withSchema(Schema.Int), Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), @@ -117,10 +112,6 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), - authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -143,7 +134,6 @@ interface CliServerFlags { readonly cwd: Option.Option; readonly devUrl: Option.Option; readonly noBrowser: Option.Option; - readonly authToken: Option.Option; readonly bootstrapFd: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; @@ -248,13 +238,9 @@ export const resolveServerConfig = ( () => mode === "desktop", ), ); - const authToken = Option.getOrUndefined( - resolveOptionPrecedence( - flags.authToken, - Option.fromUndefinedOr(env.authToken), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.authToken), - ), + const desktopBootstrapToken = Option.getOrUndefined( + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.desktopBootstrapToken), ), ); const autoBootstrapProjectFromCwd = resolveBooleanFlag( @@ -327,7 +313,7 @@ export const resolveServerConfig = ( staticDir, devUrl, noBrowser, - authToken, + desktopBootstrapToken, autoBootstrapProjectFromCwd, logWebSocketEvents, }; @@ -348,7 +334,6 @@ const commandFlags = { ), devUrl: devUrlFlag, noBrowser: noBrowserFlag, - authToken: authTokenFlag, bootstrapFd: bootstrapFdFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 887eb11c4f..14c34b8336 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -31,6 +31,7 @@ export interface ServerDerivedPaths { readonly terminalLogsDir: string; readonly anonymousIdPath: string; readonly environmentIdPath: string; + readonly secretsDir: string; } /** @@ -55,7 +56,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly staticDir: string | undefined; readonly devUrl: URL | undefined; readonly noBrowser: boolean; - readonly authToken: string | undefined; + readonly desktopBootstrapToken: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; } @@ -85,6 +86,7 @@ export const deriveServerPaths = Effect.fn(function* ( terminalLogsDir: join(logsDir, "terminals"), anonymousIdPath: join(stateDir, "anonymous-id"), environmentIdPath: join(stateDir, "environment-id"), + secretsDir: join(stateDir, "secrets"), }; }); @@ -147,7 +149,7 @@ export class ServerConfig extends ServiceMap.Service`; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; +const requireAuthenticatedRequest = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateHttpRequest(request); +}); + class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ readonly cause: unknown; readonly bodyJson: OtlpTracer.TraceData; @@ -35,6 +43,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; @@ -76,7 +85,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), ), ); - }), + }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), ).pipe( Layer.provide( HttpRouter.cors({ @@ -91,6 +100,7 @@ export const attachmentsRouteLayer = HttpRouter.add( "GET", `${ATTACHMENTS_ROUTE_PREFIX}/*`, Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -139,13 +149,14 @@ export const attachmentsRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); - }), + }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), ); export const projectFaviconRouteLayer = HttpRouter.add( "GET", "/api/project-favicon", Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -179,7 +190,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); - }), + }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), ); export const staticAndDevRouteLayer = HttpRouter.add( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 125cfd103a..82cacfe42f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -96,9 +96,12 @@ import { import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; const defaultProjectId = ProjectId.makeUnsafe("project-default"); const defaultThreadId = ThreadId.makeUnsafe("thread-default"); +const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; const defaultModelSelection = { provider: "codex", model: "gpt-5-codex", @@ -115,6 +118,7 @@ const testEnvironmentDescriptor = { repositoryIdentity: true, }, }; +let cachedDefaultSessionToken: string | null = null; const makeDefaultOrchestrationReadModel = () => { const now = new Date().toISOString(); @@ -174,6 +178,8 @@ const browserOtlpTracingLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); +const authTestLayer = ServerAuthLive.pipe(Layer.provide(ServerSecretStoreLive)); + const makeBrowserOtlpPayload = (spanName: string) => Effect.gen(function* () { const collector = yield* Effect.acquireRelease( @@ -297,6 +303,7 @@ const buildAppUnderTest = (options?: { }; }) => Effect.gen(function* () { + cachedDefaultSessionToken = null; const fileSystem = yield* FileSystem.FileSystem; const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); const baseDir = options?.config?.baseDir ?? tempBaseDir; @@ -313,7 +320,7 @@ const buildAppUnderTest = (options?: { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", - mode: "web", + mode: "desktop", port: 0, host: "127.0.0.1", cwd: process.cwd(), @@ -322,7 +329,7 @@ const buildAppUnderTest = (options?: { staticDir: undefined, devUrl, noBrowser: true, - authToken: undefined, + desktopBootstrapToken: defaultDesktopBootstrapToken, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, ...options?.config, @@ -339,6 +346,10 @@ const buildAppUnderTest = (options?: { }).pipe( Layer.provide( Layer.mock(Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), streamChanges: Stream.empty, ...options?.layers?.keybindings, }), @@ -453,6 +464,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.repositoryIdentityResolver, }), ), + Layer.provideMerge(authTestLayer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -477,6 +489,13 @@ const withWsRpcClient = ( f: (client: WsRpcClient) => Effect.Effect, ) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); +const appendSessionTokenToUrl = (url: string, sessionToken: string) => { + const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url); + const next = new URL(url, "http://localhost"); + next.searchParams.set("token", sessionToken); + return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; +}; + const getHttpServerUrl = (pathname = "") => Effect.gen(function* () { const server = yield* HttpServer.HttpServer; @@ -484,11 +503,68 @@ const getHttpServerUrl = (pathname = "") => return `http://127.0.0.1:${address.port}${pathname}`; }); -const getWsServerUrl = (pathname = "") => +const bootstrapBrowserSession = (credential = defaultDesktopBootstrapToken) => + Effect.gen(function* () { + const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap"); + const response = yield* Effect.promise(() => + fetch(bootstrapUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + credential, + }), + }), + ); + const body = (yield* Effect.promise(() => response.json())) as { + readonly authenticated: boolean; + readonly sessionMethod: string; + readonly sessionToken: string; + readonly expiresAt: string; + }; + return { + response, + body, + cookie: response.headers.get("set-cookie"), + }; + }); + +const getAuthenticatedSessionToken = (credential = defaultDesktopBootstrapToken) => + Effect.gen(function* () { + if (credential === defaultDesktopBootstrapToken && cachedDefaultSessionToken) { + return cachedDefaultSessionToken; + } + + const { response, body } = yield* bootstrapBrowserSession(credential); + if (!response.ok) { + return yield* Effect.fail( + new Error(`Expected bootstrap session response to succeed, got ${response.status}`), + ); + } + + if (credential === defaultDesktopBootstrapToken) { + cachedDefaultSessionToken = body.sessionToken; + } + + return body.sessionToken; + }); + +const getWsServerUrl = ( + pathname = "", + options?: { authenticated?: boolean; sessionToken?: string; credential?: string }, +) => Effect.gen(function* () { const server = yield* HttpServer.HttpServer; const address = server.address as HttpServer.TcpAddress; - return `ws://127.0.0.1:${address.port}${pathname}`; + const baseUrl = `ws://127.0.0.1:${address.port}${pathname}`; + if (options?.authenticated === false) { + return baseUrl; + } + return appendSessionTokenToUrl( + baseUrl, + options?.sessionToken ?? (yield* getAuthenticatedSessionToken(options?.credential)), + ); }); it.layer(NodeServices.layer)("server router seam", (it) => { @@ -539,7 +615,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + appendSessionTokenToUrl( + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + yield* getAuthenticatedSessionToken(), + ), ); assert.equal(response.status, 200); @@ -559,7 +638,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const response = yield* HttpClient.get( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + appendSessionTokenToUrl( + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + yield* getAuthenticatedSessionToken(), + ), ); assert.equal(response.status, 200); @@ -567,6 +649,105 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reports unauthenticated session state without requiring auth", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const url = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* Effect.promise(() => fetch(url)); + const body = (yield* Effect.promise(() => response.json())) as { + readonly authenticated: boolean; + readonly auth: { + readonly policy: string; + readonly bootstrapMethods: ReadonlyArray; + readonly sessionMethods: ReadonlyArray; + readonly sessionCookieName: string; + }; + }; + + assert.equal(response.status, 200); + assert.equal(body.authenticated, false); + assert.equal(body.auth.policy, "desktop-managed-local"); + assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); + assert.deepEqual(body.auth.sessionMethods, [ + "browser-session-cookie", + "bearer-session-token", + ]); + assert.equal(body.auth.sessionCookieName, "t3_session"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("bootstraps a browser session and authenticates the session endpoint via cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { + response: bootstrapResponse, + body: bootstrapBody, + cookie: setCookie, + } = yield* bootstrapBrowserSession(); + + assert.equal(bootstrapResponse.status, 200); + assert.equal(bootstrapBody.authenticated, true); + assert.equal(bootstrapBody.sessionMethod, "browser-session-cookie"); + assert.isDefined(setCookie); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const sessionResponse = yield* Effect.promise(() => + fetch(sessionUrl, { + headers: { + cookie: setCookie?.split(";")[0] ?? "", + }, + }), + ); + const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as { + readonly authenticated: boolean; + readonly sessionMethod?: string; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionBody.authenticated, true); + assert.equal(sessionBody.sessionMethod, "browser-session-cookie"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects reusing the same bootstrap credential after it has been exchanged", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const first = yield* bootstrapBrowserSession(); + const second = yield* bootstrapBrowserSession(); + + assert.equal(first.response.status, 200); + assert.equal(second.response.status, 401); + assert.equal( + (second.body as { readonly error?: string }).error, + "Invalid bootstrap credential.", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("accepts websocket rpc handshake with a bootstrapped session token", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBrowserSession(); + + assert.equal(bootstrapResponse.status, 200); + + const wsUrl = appendSessionTokenToUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + bootstrapBody.sessionToken, + ); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); + assert.equal(response.auth.policy, "desktop-managed-local"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("serves attachment files from state dir", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -583,7 +764,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); - const response = yield* HttpClient.get(`/attachments/${attachmentId}`); + const response = yield* HttpClient.get( + appendSessionTokenToUrl( + `/attachments/${attachmentId}`, + yield* getAuthenticatedSessionToken(), + ), + ); assert.equal(response.status, 200); assert.equal(yield* response.text, "attachment-ok"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -605,7 +791,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok"); const response = yield* HttpClient.get( - "/attachments/thread%20folder/message%20folder/file%20name.png", + appendSessionTokenToUrl( + "/attachments/thread%20folder/message%20folder/file%20name.png", + yield* getAuthenticatedSessionToken(), + ), ); assert.equal(response.status, 200); assert.equal(yield* response.text, "attachment-encoded-ok"); @@ -742,6 +931,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.post("/api/observability/v1/traces", { headers: { + authorization: `Bearer ${yield* getAuthenticatedSessionToken()}`, "content-type": "application/json", origin: "http://localhost:5733", }, @@ -850,6 +1040,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.post("/api/observability/v1/traces", { headers: { + authorization: `Bearer ${yield* getAuthenticatedSessionToken()}`, "content-type": "application/json", }, body: HttpBody.text(JSON.stringify(payload), "application/json"), @@ -896,7 +1087,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest(); const response = yield* HttpClient.get( - "/attachments/missing-11111111-1111-4111-8111-111111111111", + appendSessionTokenToUrl( + "/attachments/missing-11111111-1111-4111-8111-111111111111", + yield* getAuthenticatedSessionToken(), + ), ); assert.equal(response.status, 404); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -938,7 +1132,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("rejects websocket rpc handshake when auth token is missing", () => + it.effect("rejects websocket rpc handshake when session authentication is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -948,13 +1142,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "export const needle = 1;", ); - yield* buildAppUnderTest({ - config: { - authToken: "secret-token", - }, - }); + yield* buildAppUnderTest(); - const wsUrl = yield* getWsServerUrl("/ws"); + const wsUrl = yield* getWsServerUrl("/ws", { authenticated: false }); const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.projectsSearchEntries]({ @@ -970,38 +1160,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("accepts websocket rpc handshake when auth token is provided", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" }); - yield* fs.writeFileString( - path.join(workspaceDir, "needle-file.ts"), - "export const needle = 1;", - ); - - yield* buildAppUnderTest({ - config: { - authToken: "secret-token", - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws?token=secret-token"); - const response = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.projectsSearchEntries]({ - cwd: workspaceDir, - query: "needle", - limit: 10, - }), - ), - ); - - assert.isAtLeast(response.entries.length, 1); - assert.equal(response.truncated, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { const providers = [] as const; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d706d79b44..4f5a8a0f7b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -51,6 +51,9 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; +import { authBootstrapRouteLayer, authSessionRouteLayer } from "./auth/http"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -188,6 +191,8 @@ const WorkspaceLayerLive = Layer.mergeAll( ), ); +const AuthLayerLive = ServerAuthLive.pipe(Layer.provide(ServerSecretStoreLive)); + const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), @@ -203,6 +208,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), + Layer.provideMerge(AuthLayerLive), // Misc. Layer.provideMerge(AnalyticsServiceLayerLive), @@ -215,6 +221,8 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( ); export const makeRoutesLayer = Layer.mergeAll( + authBootstrapRouteLayer, + authSessionRouteLayer, attachmentsRouteLayer, otlpTracesProxyRouteLayer, projectFaviconRouteLayer, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index e94c322225..94fb5e72cf 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -29,6 +29,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerSettingsService } from "./serverSettings"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { ServerAuth } from "./auth/Services/ServerAuth"; const isWildcardHost = (host: string | undefined): boolean => host === "0.0.0.0" || host === "::" || host === "[::]"; @@ -229,18 +230,29 @@ const autoBootstrapWelcome = Effect.gen(function* () { } as const; }); -const maybeOpenBrowser = Effect.gen(function* () { +const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig; - if (serverConfig.noBrowser) { - return; - } - const { openBrowser } = yield* Open; + const serverAuth = yield* ServerAuth; const localUrl = `http://localhost:${serverConfig.port}`; const bindUrl = serverConfig.host && !isWildcardHost(serverConfig.host) ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` : localUrl; - const target = serverConfig.devUrl?.toString() ?? bindUrl; + const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; + return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( + Effect.flatMap((target) => + target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), + ), + ); +}); + +const maybeOpenBrowser = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + if (serverConfig.noBrowser) { + return; + } + const { openBrowser } = yield* Open; + const target = yield* resolveStartupBrowserTarget; yield* openBrowser(target).pipe( Effect.catch(() => @@ -371,6 +383,12 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: recording startup heartbeat"); yield* launchStartupHeartbeat; yield* Effect.logDebug("startup phase: browser open check"); + if (serverConfig.mode !== "desktop") { + const pairingUrl = yield* resolveStartupBrowserTarget; + yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.", { + pairingUrl, + }); + } yield* runStartupPhase("browser.open", maybeOpenBrowser); yield* Effect.logDebug("startup phase: complete"); }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 16e8531386..20ac1e6d8b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; +import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"; import { CommandId, EventId, @@ -20,7 +20,7 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { clamp } from "effect/Number"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; @@ -49,6 +49,8 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; +import { ServerAuth } from "./auth/Services/ServerAuth"; +import { toUnauthorizedResponse } from "./auth/http"; const WsRpcLayer = WsRpcGroup.toLayer( Effect.gen(function* () { @@ -71,6 +73,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; + const serverAuth = yield* ServerAuth; const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -377,9 +380,11 @@ const WsRpcLayer = WsRpcGroup.toLayer( const providers = yield* providerRegistry.getProviders; const settings = yield* serverSettings.getSettings; const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); return { environment, + auth, cwd: config.cwd, keybindingsConfigPath: config.keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, @@ -821,19 +826,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( "/ws", Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const config = yield* ServerConfig; - if (config.authToken) { - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 }); - } - const token = url.value.searchParams.get("token"); - if (token !== config.authToken) { - return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 }); - } - } + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateWebSocketUpgrade(request); return yield* rpcWebSocketHttpEffect; - }), + }).pipe( + Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error))), + ), ); }), ); diff --git a/apps/web/index.html b/apps/web/index.html index 45f30f7164..1223458627 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,6 +5,106 @@ + + -
+
+
+
+

T3 Code (Alpha)

+

Connecting to T3 Server

+

+ Opening the local session and waiting for the app shell to load. +

+
+
+
diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts new file mode 100644 index 0000000000..c4274aa2a3 --- /dev/null +++ b/apps/web/src/authBootstrap.test.ts @@ -0,0 +1,171 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + headers: { + "content-type": "application/json", + }, + status: 200, + ...init, + }); +} + +type TestWindow = { + location: URL; + history: { + replaceState: (_data: unknown, _unused: string, url: string) => void; + }; + desktopBridge?: DesktopBridge; +}; + +function installTestBrowser(url: string) { + const testWindow: TestWindow = { + location: new URL(url), + history: { + replaceState: (_data, _unused, nextUrl) => { + testWindow.location = new URL(nextUrl); + }, + }, + }; + + vi.stubGlobal("window", testWindow); + vi.stubGlobal("document", { title: "T3 Code" }); + + return testWindow; +} + +describe("resolveInitialServerAuthGateState", () => { + beforeEach(() => { + vi.restoreAllMocks(); + installTestBrowser("http://localhost/"); + }); + + afterEach(async () => { + const { __resetServerAuthBootstrapForTests } = await import("./authBootstrap"); + __resetServerAuthBootstrapForTests(); + vi.restoreAllMocks(); + }); + + it("reuses an in-flight silent bootstrap attempt", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + sessionToken: "session-token", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + wsUrl: "ws://localhost:3773/ws", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toEqual( + new URL("/api/auth/session", "ws://localhost:3773/"), + ); + expect(fetchMock.mock.calls[1]?.[0]).toEqual( + new URL("/api/auth/bootstrap", "ws://localhost:3773/"), + ); + }); + + it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + }); + + it("takes a pairing token from the location and strips it immediately", async () => { + const testWindow = installTestBrowser("http://localhost/?token=pairing-token"); + const { takePairingTokenFromUrl } = await import("./authBootstrap"); + + expect(takePairingTokenFromUrl()).toBe("pairing-token"); + expect(testWindow.location.searchParams.get("token")).toBeNull(); + }); + + it("allows manual token submission after the initial auth check requires pairing", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + sessionToken: "session-token", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + installTestBrowser("http://localhost/"); + + const { resolveInitialServerAuthGateState, submitServerAuthCredential } = + await import("./authBootstrap"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts new file mode 100644 index 0000000000..ca833a9519 --- /dev/null +++ b/apps/web/src/authBootstrap.ts @@ -0,0 +1,134 @@ +import type { AuthBootstrapInput, AuthBootstrapResult, AuthSessionState } from "@t3tools/contracts"; + +import { resolvePrimaryEnvironmentBootstrapUrl } from "./environmentBootstrap"; + +export type ServerAuthGateState = + | { status: "authenticated" } + | { + status: "requires-auth"; + auth: AuthSessionState["auth"]; + errorMessage?: string; + }; + +let bootstrapPromise: Promise | null = null; + +export function peekPairingTokenFromUrl(): string | null { + const url = new URL(window.location.href); + const token = url.searchParams.get("token"); + return token && token.length > 0 ? token : null; +} + +export function stripPairingTokenFromUrl() { + const url = new URL(window.location.href); + if (!url.searchParams.has("token")) { + return; + } + url.searchParams.delete("token"); + window.history.replaceState({}, document.title, url.toString()); +} + +export function takePairingTokenFromUrl(): string | null { + const token = peekPairingTokenFromUrl(); + if (!token) { + return null; + } + stripPairingTokenFromUrl(); + return token; +} + +function getBootstrapCredential(): string | null { + return getDesktopBootstrapCredential(); +} + +function getDesktopBootstrapCredential(): string | null { + const bootstrap = window.desktopBridge?.getLocalEnvironmentBootstrap(); + return typeof bootstrap?.bootstrapToken === "string" && bootstrap.bootstrapToken.length > 0 + ? bootstrap.bootstrapToken + : null; +} + +async function fetchSessionState(baseUrl: string): Promise { + const response = await fetch(new URL("/api/auth/session", baseUrl), { + credentials: "include", + }); + if (!response.ok) { + throw new Error(`Failed to load auth session state (${response.status}).`); + } + return (await response.json()) as AuthSessionState; +} + +async function exchangeBootstrapCredential( + baseUrl: string, + credential: string, +): Promise { + const payload: AuthBootstrapInput = { credential }; + const response = await fetch(new URL("/api/auth/bootstrap", baseUrl), { + body: JSON.stringify(payload), + credentials: "include", + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Failed to bootstrap auth session (${response.status}).`); + } + + return (await response.json()) as AuthBootstrapResult; +} + +async function bootstrapServerAuth(): Promise { + const baseUrl = resolvePrimaryEnvironmentBootstrapUrl(); + const bootstrapCredential = getBootstrapCredential(); + const currentSession = await fetchSessionState(baseUrl); + if (currentSession.authenticated) { + return { status: "authenticated" }; + } + + if (!bootstrapCredential) { + return { + status: "requires-auth", + auth: currentSession.auth, + }; + } + + try { + await exchangeBootstrapCredential(baseUrl, bootstrapCredential); + return { status: "authenticated" }; + } catch (error) { + return { + status: "requires-auth", + auth: currentSession.auth, + errorMessage: error instanceof Error ? error.message : "Authentication failed.", + }; + } +} + +export async function submitServerAuthCredential(credential: string): Promise { + const trimmedCredential = credential.trim(); + if (!trimmedCredential) { + throw new Error("Enter a pairing token to continue."); + } + + await exchangeBootstrapCredential(resolvePrimaryEnvironmentBootstrapUrl(), trimmedCredential); + stripPairingTokenFromUrl(); +} + +export function resolveInitialServerAuthGateState(): Promise { + if (bootstrapPromise) { + return bootstrapPromise; + } + + bootstrapPromise = bootstrapServerAuth().catch((error) => { + bootstrapPromise = null; + throw error; + }); + + return bootstrapPromise; +} + +export function __resetServerAuthBootstrapForTests() { + bootstrapPromise = null; +} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 3f536dd836..2189978f23 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -148,6 +148,12 @@ function createBaseServerConfig(): ServerConfig { serverVersion: "0.0.0-test", capabilities: { repositoryIdentity: true }, }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 6ec450d662..a7bbf26644 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -58,6 +58,12 @@ function createBaseServerConfig(): ServerConfig { serverVersion: "0.0.0-test", capabilities: { repositoryIdentity: true }, }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx new file mode 100644 index 0000000000..477f1ddeb4 --- /dev/null +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -0,0 +1,172 @@ +import type { AuthSessionState } from "@t3tools/contracts"; +import { + startTransition, + type FormEvent, + useEffect, + useEffectEvent, + useRef, + useState, +} from "react"; + +import { APP_DISPLAY_NAME } from "../../branding"; +import { + peekPairingTokenFromUrl, + stripPairingTokenFromUrl, + submitServerAuthCredential, +} from "../../authBootstrap"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +export function PairingRouteSurface({ + auth, + initialErrorMessage, + onAuthenticated, +}: { + auth: AuthSessionState["auth"]; + initialErrorMessage?: string; + onAuthenticated: () => void; +}) { + const autoPairTokenRef = useRef(peekPairingTokenFromUrl()); + const [credential, setCredential] = useState(() => autoPairTokenRef.current ?? ""); + const [errorMessage, setErrorMessage] = useState(initialErrorMessage ?? ""); + const [isSubmitting, setIsSubmitting] = useState(false); + const autoSubmitAttemptedRef = useRef(false); + + const submitCredential = useEffectEvent(async (nextCredential: string) => { + setIsSubmitting(true); + setErrorMessage(""); + + const submitError = await submitServerAuthCredential(nextCredential).then( + () => null, + (error) => errorMessageFromUnknown(error), + ); + + setIsSubmitting(false); + + if (submitError) { + setErrorMessage(submitError); + return; + } + + startTransition(() => { + onAuthenticated(); + }); + }); + + const handleSubmit = useEffectEvent(async (event?: FormEvent) => { + event?.preventDefault(); + await submitCredential(credential); + }); + + useEffect(() => { + const token = autoPairTokenRef.current; + if (!token || autoSubmitAttemptedRef.current) { + return; + } + + autoSubmitAttemptedRef.current = true; + stripPairingTokenFromUrl(); + void submitCredential(token); + }, []); + + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ Pair with this environment +

+

+ {describeAuthGate(auth.bootstrapMethods)} +

+ +
void handleSubmit(event)}> +
+ + setCredential(event.currentTarget.value)} + placeholder="Paste a one-time token or pairing secret" + spellCheck={false} + value={credential} + /> +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ + +
+
+ +
+ {describeSupportedMethods(auth.bootstrapMethods)} +
+
+
+ ); +} + +function errorMessageFromUnknown(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + return "Authentication failed."; +} + +function describeAuthGate(bootstrapMethods: ReadonlyArray): string { + if (bootstrapMethods.includes("desktop-bootstrap")) { + return "This environment expects a trusted pairing credential before the app can connect."; + } + + return "Enter a pairing token to start a session with this environment."; +} + +function describeSupportedMethods(bootstrapMethods: ReadonlyArray): string { + if ( + bootstrapMethods.includes("desktop-bootstrap") && + bootstrapMethods.includes("one-time-token") + ) { + return "Desktop-managed pairing and one-time pairing tokens are both accepted for this environment."; + } + + if (bootstrapMethods.includes("desktop-bootstrap")) { + return "This environment is desktop-managed. Open it from the desktop app or paste a bootstrap credential if one was issued explicitly."; + } + + return "This environment accepts one-time pairing tokens. Pairing links can open this page directly, or you can paste the token here."; +} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index ab2c8ab3f1..c28538aa1d 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -24,6 +24,12 @@ function createBaseServerConfig(): ServerConfig { serverVersion: "0.0.0-test", capabilities: { repositoryIdentity: true }, }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index f8d5e531f9..34b6b49945 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -185,6 +185,12 @@ const baseEnvironment = { const baseServerConfig: ServerConfig = { environment: baseEnvironment, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index dd69738de3..9ffdb142a5 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' +import { Route as PairRouteImport } from './routes/pair' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' @@ -22,6 +23,11 @@ const SettingsRoute = SettingsRouteImport.update({ path: '/settings', getParentRoute: () => rootRouteImport, } as any) +const PairRoute = PairRouteImport.update({ + id: '/pair', + path: '/pair', + getParentRoute: () => rootRouteImport, +} as any) const ChatRoute = ChatRouteImport.update({ id: '/_chat', getParentRoute: () => rootRouteImport, @@ -55,6 +61,7 @@ const ChatEnvironmentIdThreadIdRoute = export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute + '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute @@ -62,6 +69,7 @@ export interface FileRoutesByFullPath { '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesByTo { + '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute @@ -72,6 +80,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren + '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute @@ -83,6 +92,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/pair' | '/settings' | '/settings/archived' | '/settings/general' @@ -90,6 +100,7 @@ export interface FileRouteTypes { | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo to: + | '/pair' | '/settings' | '/settings/archived' | '/settings/general' @@ -99,6 +110,7 @@ export interface FileRouteTypes { id: | '__root__' | '/_chat' + | '/pair' | '/settings' | '/settings/archived' | '/settings/general' @@ -109,6 +121,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren + PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren } @@ -121,6 +134,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/pair': { + id: '/pair' + path: '/pair' + fullPath: '/pair' + preLoaderRoute: typeof PairRouteImport + parentRoute: typeof rootRouteImport + } '/_chat': { id: '/_chat' path: '' @@ -196,6 +216,7 @@ const SettingsRouteWithChildren = SettingsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, + PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8b24198527..4d3c0c7da6 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -14,8 +14,8 @@ import { Outlet, createRootRouteWithContext, type ErrorComponentProps, - useNavigate, useLocation, + useNavigate, } from "@tanstack/react-router"; import { useEffect, useEffectEvent, useRef, useState } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; @@ -68,28 +68,40 @@ import { subscribeWsRpcClientRegistry, type WsRpcClientEntry, } from "~/wsRpcClient"; +import { resolveInitialServerAuthGateState } from "../authBootstrap"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; }>()({ + beforeLoad: async () => ({ + authGateState: await resolveInitialServerAuthGateState(), + }), component: RootRouteView, errorComponent: RootRouteErrorView, + pendingComponent: RootRoutePendingView, head: () => ({ meta: [{ name: "title", content: APP_DISPLAY_NAME }], }), }); function RootRouteView() { + const pathname = useLocation({ select: (location) => location.pathname }); + const { authGateState } = Route.useRouteContext(); + + if (!authGateState) { + return ; + } + + if (pathname === "/pair") { + return ; + } + + if (authGateState.status !== "authenticated") { + return ; + } + if (!readLocalApi()) { - return ( -
-
-

- Connecting to {APP_DISPLAY_NAME} server... -

-
-
- ); + return ; } return ( @@ -109,6 +121,16 @@ function RootRouteView() { ); } +function RootRoutePendingView() { + return ( +
+
+

Connecting to {APP_DISPLAY_NAME} server...

+
+
+ ); +} + function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 7491fce005..70e6626b91 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,7 +1,8 @@ import { scopeProjectRef } from "@t3tools/client-runtime"; -import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect } from "react"; +import { resolveInitialServerAuthGateState } from "../authBootstrap"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; @@ -102,5 +103,11 @@ function ChatRouteLayout() { } export const Route = createFileRoute("/_chat")({ + beforeLoad: async () => { + const authGateState = await resolveInitialServerAuthGateState(); + if (authGateState.status !== "authenticated") { + throw redirect({ to: "/pair", replace: true }); + } + }, component: ChatRouteLayout, }); diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx new file mode 100644 index 0000000000..6e30ed00d0 --- /dev/null +++ b/apps/web/src/routes/pair.tsx @@ -0,0 +1,36 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; + +import { PairingRouteSurface } from "../components/auth/PairingRouteSurface"; +import { resolveInitialServerAuthGateState } from "../authBootstrap"; + +export const Route = createFileRoute("/pair")({ + beforeLoad: async () => { + const authGateState = await resolveInitialServerAuthGateState(); + if (authGateState.status === "authenticated") { + throw redirect({ to: "/", replace: true }); + } + return { + authGateState, + }; + }, + component: PairRouteView, +}); + +function PairRouteView() { + const { authGateState } = Route.useRouteContext(); + const navigate = useNavigate(); + + if (!authGateState) { + return null; + } + + return ( + { + void navigate({ to: "/", replace: true }); + }} + {...(authGateState.errorMessage ? { initialErrorMessage: authGateState.errorMessage } : {})} + /> + ); +} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 45096fd6d6..01404c7e37 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -2,6 +2,7 @@ import { RotateCcwIcon } from "lucide-react"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { resolveInitialServerAuthGateState } from "../authBootstrap"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; @@ -83,7 +84,12 @@ function SettingsRouteLayout() { } export const Route = createFileRoute("/settings")({ - beforeLoad: ({ location }) => { + beforeLoad: async ({ location }) => { + const authGateState = await resolveInitialServerAuthGateState(); + if (authGateState.status !== "authenticated") { + throw redirect({ to: "/pair", replace: true }); + } + if (location.pathname === "/settings") { throw redirect({ to: "/settings/general", replace: true }); } diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 4eb198324d..5ee6d6807f 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -66,6 +66,12 @@ const baseEnvironment = { const baseServerConfig: ServerConfig = { environment: baseEnvironment, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts new file mode 100644 index 0000000000..b7312d7185 --- /dev/null +++ b/packages/contracts/src/auth.ts @@ -0,0 +1,49 @@ +import { Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas"; + +export const ServerAuthPolicy = Schema.Literals([ + "desktop-managed-local", + "loopback-browser", + "remote-reachable", + "unsafe-no-auth", +]); +export type ServerAuthPolicy = typeof ServerAuthPolicy.Type; + +export const ServerAuthBootstrapMethod = Schema.Literals(["desktop-bootstrap", "one-time-token"]); +export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type; + +export const ServerAuthSessionMethod = Schema.Literals([ + "browser-session-cookie", + "bearer-session-token", +]); +export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; + +export const ServerAuthDescriptor = Schema.Struct({ + policy: ServerAuthPolicy, + bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod), + sessionMethods: Schema.Array(ServerAuthSessionMethod), + sessionCookieName: TrimmedNonEmptyString, +}); +export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type; + +export const AuthBootstrapInput = Schema.Struct({ + credential: TrimmedNonEmptyString, +}); +export type AuthBootstrapInput = typeof AuthBootstrapInput.Type; + +export const AuthBootstrapResult = Schema.Struct({ + authenticated: Schema.Literal(true), + sessionMethod: ServerAuthSessionMethod, + sessionToken: TrimmedNonEmptyString, + expiresAt: Schema.DateTimeUtc, +}); +export type AuthBootstrapResult = typeof AuthBootstrapResult.Type; + +export const AuthSessionState = Schema.Struct({ + authenticated: Schema.Boolean, + auth: ServerAuthDescriptor, + sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), + expiresAt: Schema.optionalKey(Schema.DateTimeUtc), +}); +export type AuthSessionState = typeof AuthSessionState.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d2f84eda9d..f12cf80d57 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas"; +export * from "./auth"; export * from "./environment"; export * from "./ipc"; export * from "./terminal"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1a08d5208c..815cd4b140 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -108,6 +108,7 @@ export interface DesktopUpdateCheckResult { export interface DesktopEnvironmentBootstrap { label: string; wsUrl: string | null; + bootstrapToken?: string; } export interface DesktopBridge { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9227f4d8c9..a4e33c990b 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { ExecutionEnvironmentDescriptor } from "./environment"; +import { ServerAuthDescriptor } from "./auth"; import { IsoDateTime, NonNegativeInt, @@ -85,6 +86,7 @@ export type ServerObservability = typeof ServerObservability.Type; export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, + auth: ServerAuthDescriptor, cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index d3e19e55c2..8005c00107 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -54,7 +54,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: undefined, - authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, @@ -75,7 +74,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: "/tmp/custom-t3", - authToken: "secret", noBrowser: true, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, @@ -105,7 +103,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: undefined, - authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, @@ -127,7 +124,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: undefined, - authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: false, @@ -148,7 +144,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: "/tmp/my-t3", - authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, @@ -167,7 +162,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { mode: "dev:desktop", baseEnv: { T3CODE_PORT: "3773", - T3CODE_AUTH_TOKEN: "stale-token", T3CODE_MODE: "web", T3CODE_NO_BROWSER: "0", T3CODE_HOST: "0.0.0.0", @@ -176,7 +170,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { serverOffset: 0, webOffset: 0, t3Home: "/tmp/my-t3", - authToken: "fresh-token", noBrowser: true, autoBootstrapProjectFromCwd: undefined, logWebSocketEvents: undefined, @@ -190,7 +183,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.ELECTRON_RENDERER_PORT, "5733"); assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:5733"); assert.equal(env.T3CODE_PORT, undefined); - assert.equal(env.T3CODE_AUTH_TOKEN, undefined); assert.equal(env.T3CODE_MODE, undefined); assert.equal(env.T3CODE_NO_BROWSER, undefined); assert.equal(env.T3CODE_HOST, undefined); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 852f21ad01..7089450e89 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -120,7 +120,6 @@ interface CreateDevRunnerEnvInput { readonly serverOffset: number; readonly webOffset: number; readonly t3Home: string | undefined; - readonly authToken: string | undefined; readonly noBrowser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; readonly logWebSocketEvents: boolean | undefined; @@ -135,7 +134,6 @@ export function createDevRunnerEnv({ serverOffset, webOffset, t3Home, - authToken, noBrowser, autoBootstrapProjectFromCwd, logWebSocketEvents, @@ -163,7 +161,6 @@ export function createDevRunnerEnv({ } else { delete output.T3CODE_PORT; delete output.VITE_WS_URL; - delete output.T3CODE_AUTH_TOKEN; delete output.T3CODE_MODE; delete output.T3CODE_NO_BROWSER; delete output.T3CODE_HOST; @@ -173,12 +170,6 @@ export function createDevRunnerEnv({ output.T3CODE_HOST = host; } - if (!isDesktopMode && authToken !== undefined) { - output.T3CODE_AUTH_TOKEN = authToken; - } else if (!isDesktopMode) { - delete output.T3CODE_AUTH_TOKEN; - } - if (!isDesktopMode && noBrowser !== undefined) { output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; } else if (!isDesktopMode) { @@ -350,7 +341,6 @@ export function resolveModePortOffsets({ interface DevRunnerCliInput { readonly mode: DevMode; readonly t3Home: string | undefined; - readonly authToken: string | undefined; readonly noBrowser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; readonly logWebSocketEvents: boolean | undefined; @@ -430,7 +420,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { serverOffset, webOffset, t3Home: input.t3Home, - authToken: input.authToken, noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride( input.autoBootstrapProjectFromCwd, @@ -503,11 +492,6 @@ const devRunnerCli = Command.make("dev-runner", { Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")), ), - authToken: Flag.string("auth-token").pipe( - Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."), - Flag.withAlias("token"), - Flag.withFallbackConfig(optionalStringConfig("T3CODE_AUTH_TOKEN")), - ), noBrowser: Flag.boolean("no-browser").pipe( Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."), Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")), From 925bb3f89ff0278c6a9da3a10897964906752009 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 20:36:21 -0700 Subject: [PATCH 02/16] Address review comments on auth pairing Co-authored-by: codex --- apps/desktop/src/main.ts | 10 ++- .../Layers/BootstrapCredentialService.test.ts | 23 +++++ .../auth/Layers/BootstrapCredentialService.ts | 84 +++++++++++++------ apps/server/src/auth/Layers/ServerAuth.ts | 1 + .../src/auth/Layers/ServerSecretStore.test.ts | 42 ++++++++++ .../src/auth/Layers/ServerSecretStore.ts | 6 +- apps/server/src/serverRuntimeStartup.ts | 38 ++++----- apps/web/src/authBootstrap.test.ts | 2 +- 8 files changed, 157 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e585eb076b..e651cc35a7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1465,7 +1465,7 @@ function createWindow(): BrowserWindow { void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); window.webContents.openDevTools({ mode: "detach" }); } else { - void window.loadURL(backendHttpUrl); + void window.loadURL(resolveDesktopWindowUrl()); } window.on("closed", () => { @@ -1477,6 +1477,14 @@ function createWindow(): BrowserWindow { return window; } +function resolveDesktopWindowUrl(): string { + if (backendHttpUrl) { + return backendHttpUrl; + } + + return `${DESKTOP_SCHEME}://app`; +} + // Override Electron's userData path before the `ready` event so that // Chromium session data uses a filesystem-friendly directory name. // Must be called synchronously at the top level — before `app.whenReady()`. diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts index 5e575fe228..1ff9ef5564 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts @@ -41,6 +41,29 @@ it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { }).pipe(Effect.provide(makeBootstrapCredentialLayer())), ); + it.effect("atomically consumes a one-time token when multiple requests race", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const token = yield* bootstrapCredentials.issueOneTimeToken(); + const results = yield* Effect.all( + Array.from({ length: 8 }, () => Effect.result(bootstrapCredentials.consume(token))), + { + concurrency: "unbounded", + }, + ); + + const successes = results.filter((result) => result._tag === "Success"); + const failures = results.filter((result) => result._tag === "Failure"); + + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(7); + for (const failure of failures) { + expect(failure.failure._tag).toBe("BootstrapCredentialError"); + expect(failure.failure.message).toContain("Unknown bootstrap credential"); + } + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); + it.effect("seeds the desktop bootstrap credential as a one-time grant", () => Effect.gen(function* () { const bootstrapCredentials = yield* BootstrapCredentialService; diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts index 240503dcdd..6049f78184 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -12,6 +12,16 @@ interface StoredBootstrapGrant extends BootstrapGrant { readonly remainingUses: number | "unbounded"; } +type ConsumeResult = + | { + readonly _tag: "error"; + readonly error: BootstrapCredentialError; + } + | { + readonly _tag: "success"; + readonly grant: BootstrapGrant; + }; + const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); export const makeBootstrapCredentialService = Effect.gen(function* () { @@ -51,29 +61,40 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { const consume: BootstrapCredentialServiceShape["consume"] = (credential) => Effect.gen(function* () { - const current = yield* Ref.get(grantsRef); - const grant = current.get(credential); - if (!grant) { - return yield* new BootstrapCredentialError({ - message: "Unknown bootstrap credential.", - }); - } + const now = yield* DateTime.now; + const result: ConsumeResult = yield* Ref.modify(grantsRef, (current): readonly [ + ConsumeResult, + Map, + ] => { + const grant = current.get(credential); + if (!grant) { + return [ + { + _tag: "error", + error: new BootstrapCredentialError({ + message: "Unknown bootstrap credential.", + }), + }, + current, + ]; + } - if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, grant.expiresAt)) { - yield* Ref.update(grantsRef, (state) => { - const next = new Map(state); + const next = new Map(current); + if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { next.delete(credential); - return next; - }); - return yield* new BootstrapCredentialError({ - message: "Bootstrap credential expired.", - }); - } + return [ + { + _tag: "error", + error: new BootstrapCredentialError({ + message: "Bootstrap credential expired.", + }), + }, + next, + ]; + } - const remainingUses = grant.remainingUses; - if (typeof remainingUses === "number") { - yield* Ref.update(grantsRef, (state) => { - const next = new Map(state); + const remainingUses = grant.remainingUses; + if (typeof remainingUses === "number") { if (remainingUses <= 1) { next.delete(credential); } else { @@ -82,14 +103,25 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { remainingUses: remainingUses - 1, }); } - return next; - }); + } + + return [ + { + _tag: "success", + grant: { + method: grant.method, + expiresAt: grant.expiresAt, + } satisfies BootstrapGrant, + }, + next, + ]; + }); + + if (result._tag === "error") { + return yield* result.error; } - return { - method: grant.method, - expiresAt: grant.expiresAt, - } satisfies BootstrapGrant; + return result.grant; }); return { diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 9e5fefbfb4..a8fc430bb4 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -124,6 +124,7 @@ export const makeServerAuth = Effect.gen(function* () { bootstrapCredentials.issueOneTimeToken().pipe( Effect.map((credential) => { const url = new URL(baseUrl); + url.pathname = "/pair"; url.searchParams.set("token", credential); return url.toString(); }), diff --git a/apps/server/src/auth/Layers/ServerSecretStore.test.ts b/apps/server/src/auth/Layers/ServerSecretStore.test.ts index 258d03bec7..f53e990c77 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.test.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.test.ts @@ -40,6 +40,33 @@ const makePermissionDeniedSecretStoreLayer = () => Layer.provide(PermissionDeniedFileSystemLayer), ); +const RenameFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + rename: (from, to) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + pathOrDescriptor: `${String(from)} -> ${String(to)}`, + description: "Permission denied while persisting secret file.", + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeRenameFailureSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provide(RenameFailureFileSystemLayer), + ); + it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { it.effect("returns null when a secret file does not exist", () => Effect.gen(function* () { @@ -74,4 +101,19 @@ it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), ); + + it.effect("propagates write failures instead of treating them as success", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip( + secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), + ); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to persist secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), + ); }); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts index 4c5b5a16ad..c8e0799c99 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -47,8 +47,10 @@ export const makeServerSecretStore = Effect.gen(function* () { Effect.catch((cause) => fileSystem.remove(tempPath).pipe( Effect.orElseSucceed(() => undefined), - Effect.map( - () => new SecretStoreError({ message: `Failed to persist secret ${name}.`, cause }), + Effect.flatMap(() => + Effect.fail( + new SecretStoreError({ message: `Failed to persist secret ${name}.`, cause }), + ), ), ), ), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 94fb5e72cf..427b2e3902 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -246,22 +246,22 @@ const resolveStartupBrowserTarget = Effect.gen(function* () { ); }); -const maybeOpenBrowser = Effect.gen(function* () { - const serverConfig = yield* ServerConfig; - if (serverConfig.noBrowser) { - return; - } - const { openBrowser } = yield* Open; - const target = yield* resolveStartupBrowserTarget; - - yield* openBrowser(target).pipe( - Effect.catch(() => - Effect.logInfo("browser auto-open unavailable", { - hint: `Open ${target} in your browser.`, - }), - ), - ); -}); +const maybeOpenBrowser = (target: string) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + if (serverConfig.noBrowser) { + return; + } + const { openBrowser } = yield* Open; + + yield* openBrowser(target).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); + }); const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( @@ -383,13 +383,13 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: recording startup heartbeat"); yield* launchStartupHeartbeat; yield* Effect.logDebug("startup phase: browser open check"); + const startupBrowserTarget = yield* resolveStartupBrowserTarget; if (serverConfig.mode !== "desktop") { - const pairingUrl = yield* resolveStartupBrowserTarget; yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.", { - pairingUrl, + pairingUrl: startupBrowserTarget, }); } - yield* runStartupPhase("browser.open", maybeOpenBrowser); + yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); yield* Effect.logDebug("startup phase: complete"); }), ); diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index c4274aa2a3..32ea839f30 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -24,7 +24,7 @@ function installTestBrowser(url: string) { location: new URL(url), history: { replaceState: (_data, _unused, nextUrl) => { - testWindow.location = new URL(nextUrl); + testWindow.location = new URL(nextUrl, testWindow.location.href); }, }, }; From af1dfdb73099fa9214159ada8554e56db5e2564b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 20:43:30 -0700 Subject: [PATCH 03/16] Fix bootstrap credential handling and secret cleanup - Decrement remaining uses atomically on consume - Clean up temp files without masking secret persistence errors --- .../auth/Layers/BootstrapCredentialService.ts | 96 +++++++++---------- .../src/auth/Layers/ServerSecretStore.ts | 7 +- 2 files changed, 53 insertions(+), 50 deletions(-) diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts index 6049f78184..4423a000cc 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -62,60 +62,60 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { const consume: BootstrapCredentialServiceShape["consume"] = (credential) => Effect.gen(function* () { const now = yield* DateTime.now; - const result: ConsumeResult = yield* Ref.modify(grantsRef, (current): readonly [ - ConsumeResult, - Map, - ] => { - const grant = current.get(credential); - if (!grant) { - return [ - { - _tag: "error", - error: new BootstrapCredentialError({ - message: "Unknown bootstrap credential.", - }), - }, - current, - ]; - } + const result: ConsumeResult = yield* Ref.modify( + grantsRef, + (current): readonly [ConsumeResult, Map] => { + const grant = current.get(credential); + if (!grant) { + return [ + { + _tag: "error", + error: new BootstrapCredentialError({ + message: "Unknown bootstrap credential.", + }), + }, + current, + ]; + } + + const next = new Map(current); + if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { + next.delete(credential); + return [ + { + _tag: "error", + error: new BootstrapCredentialError({ + message: "Bootstrap credential expired.", + }), + }, + next, + ]; + } + + const remainingUses = grant.remainingUses; + if (typeof remainingUses === "number") { + if (remainingUses <= 1) { + next.delete(credential); + } else { + next.set(credential, { + ...grant, + remainingUses: remainingUses - 1, + }); + } + } - const next = new Map(current); - if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { - next.delete(credential); return [ { - _tag: "error", - error: new BootstrapCredentialError({ - message: "Bootstrap credential expired.", - }), + _tag: "success", + grant: { + method: grant.method, + expiresAt: grant.expiresAt, + } satisfies BootstrapGrant, }, next, ]; - } - - const remainingUses = grant.remainingUses; - if (typeof remainingUses === "number") { - if (remainingUses <= 1) { - next.delete(credential); - } else { - next.set(credential, { - ...grant, - remainingUses: remainingUses - 1, - }); - } - } - - return [ - { - _tag: "success", - grant: { - method: grant.method, - expiresAt: grant.expiresAt, - } satisfies BootstrapGrant, - }, - next, - ]; - }); + }, + ); if (result._tag === "error") { return yield* result.error; diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts index c8e0799c99..08477afcba 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -46,10 +46,13 @@ export const makeServerSecretStore = Effect.gen(function* () { }).pipe( Effect.catch((cause) => fileSystem.remove(tempPath).pipe( - Effect.orElseSucceed(() => undefined), + Effect.ignore, Effect.flatMap(() => Effect.fail( - new SecretStoreError({ message: `Failed to persist secret ${name}.`, cause }), + new SecretStoreError({ + message: `Failed to persist secret ${name}.`, + cause, + }), ), ), ), From 6284732ae4d5e351c07f78a258364b1cb827029d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 20:55:20 -0700 Subject: [PATCH 04/16] Resolve auth bootstrap URLs from HTTP origins - Convert ws/wss environment URLs to fetchable http/https bases - Cover remote pairing auth bootstrap and URL resolution with tests --- apps/web/src/authBootstrap.test.ts | 39 ++++++++++++++++++- apps/web/src/authBootstrap.ts | 15 +++++-- .../src/knownEnvironment.test.ts | 25 +++++++++++- .../client-runtime/src/knownEnvironment.ts | 34 +++++++++++++++- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 32ea839f30..29a145ea13 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -86,10 +86,45 @@ describe("resolveInitialServerAuthGateState", () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0]?.[0]).toEqual( - new URL("/api/auth/session", "ws://localhost:3773/"), + new URL("/api/auth/session", "http://localhost:3773/"), ); expect(fetchMock.mock.calls[1]?.[0]).toEqual( - new URL("/api/auth/bootstrap", "ws://localhost:3773/"), + new URL("/api/auth/bootstrap", "http://localhost:3773/"), + ); + }); + + it("uses https fetch urls when the primary environment uses wss", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("VITE_WS_URL", "wss://remote.example.com/ws"); + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + new URL("/api/auth/session", "https://remote.example.com/ws"), + { + credentials: "include", + }, ); }); diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts index ca833a9519..088e0a5c49 100644 --- a/apps/web/src/authBootstrap.ts +++ b/apps/web/src/authBootstrap.ts @@ -1,6 +1,7 @@ import type { AuthBootstrapInput, AuthBootstrapResult, AuthSessionState } from "@t3tools/contracts"; +import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; -import { resolvePrimaryEnvironmentBootstrapUrl } from "./environmentBootstrap"; +import { getPrimaryKnownEnvironment } from "./environmentBootstrap"; export type ServerAuthGateState = | { status: "authenticated" } @@ -47,6 +48,14 @@ function getDesktopBootstrapCredential(): string | null { : null; } +function resolvePrimaryEnvironmentHttpBaseUrl(): string { + const baseUrl = getKnownEnvironmentHttpBaseUrl(getPrimaryKnownEnvironment()); + if (!baseUrl) { + throw new Error("Unable to resolve a known environment bootstrap URL."); + } + return baseUrl; +} + async function fetchSessionState(baseUrl: string): Promise { const response = await fetch(new URL("/api/auth/session", baseUrl), { credentials: "include", @@ -80,7 +89,7 @@ async function exchangeBootstrapCredential( } async function bootstrapServerAuth(): Promise { - const baseUrl = resolvePrimaryEnvironmentBootstrapUrl(); + const baseUrl = resolvePrimaryEnvironmentHttpBaseUrl(); const bootstrapCredential = getBootstrapCredential(); const currentSession = await fetchSessionState(baseUrl); if (currentSession.authenticated) { @@ -112,7 +121,7 @@ export async function submitServerAuthCredential(credential: string): Promise { }, }); }); + + it("converts websocket base urls into fetchable http origins", () => { + expect( + getKnownEnvironmentHttpBaseUrl( + createKnownEnvironmentFromWsUrl({ + label: "Local environment", + wsUrl: "ws://localhost:3773/ws", + }), + ), + ).toBe("http://localhost:3773/ws"); + + expect( + getKnownEnvironmentHttpBaseUrl( + createKnownEnvironmentFromWsUrl({ + label: "Remote environment", + wsUrl: "wss://remote.example.com/api/ws", + }), + ), + ).toBe("https://remote.example.com/api/ws"); + }); }); describe("scoped refs", () => { diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/knownEnvironment.ts index 3a5e0d0e7d..a2d11485af 100644 --- a/packages/client-runtime/src/knownEnvironment.ts +++ b/packages/client-runtime/src/knownEnvironment.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; export interface KnownEnvironmentConnectionTarget { readonly type: "ws"; @@ -37,3 +37,35 @@ export function getKnownEnvironmentBaseUrl( ): string | null { return environment?.target.wsUrl ?? null; } + +export function getKnownEnvironmentHttpBaseUrl( + environment: KnownEnvironment | null | undefined, +): string | null { + const baseUrl = getKnownEnvironmentBaseUrl(environment); + if (!baseUrl) { + return null; + } + + try { + const url = new URL(baseUrl); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + return url.toString(); + } catch { + return baseUrl; + } +} + +export function attachEnvironmentDescriptor( + environment: KnownEnvironment, + descriptor: ExecutionEnvironmentDescriptor, +): KnownEnvironment { + return { + ...environment, + environmentId: descriptor.environmentId, + label: descriptor.label, + }; +} From a8dedfe79b9b875cf570dd67707ddbc09d862010 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 21:00:40 -0700 Subject: [PATCH 05/16] Handle secret removal failures explicitly - Treat missing-file removals as success - Wrap other remove failures in SecretStoreError - Co-authored-by: codex --- .../src/auth/Layers/ServerSecretStore.test.ts | 40 +++++++++++++++++++ .../src/auth/Layers/ServerSecretStore.ts | 13 +++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/server/src/auth/Layers/ServerSecretStore.test.ts b/apps/server/src/auth/Layers/ServerSecretStore.test.ts index f53e990c77..b331e476a2 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.test.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.test.ts @@ -67,6 +67,33 @@ const makeRenameFailureSecretStoreLayer = () => Layer.provide(RenameFailureFileSystemLayer), ); +const RemoveFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + remove: (path, options) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: String(path), + description: `Permission denied while removing secret file.${options ? " options-set" : ""}`, + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeRemoveFailureSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provide(RemoveFailureFileSystemLayer), + ); + it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { it.effect("returns null when a secret file does not exist", () => Effect.gen(function* () { @@ -116,4 +143,17 @@ it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), ); + + it.effect("propagates remove failures other than missing-file errors", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip(secretStore.remove("session-signing-key")); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to remove secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), + ); }); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts index 08477afcba..033d84c5fe 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -73,7 +73,18 @@ export const makeServerSecretStore = Effect.gen(function* () { ); const remove: ServerSecretStoreShape["remove"] = (name) => - fileSystem.remove(resolveSecretPath(name)).pipe(Effect.orElseSucceed(() => undefined)); + fileSystem.remove(resolveSecretPath(name)).pipe( + Effect.catch((cause) => + isMissingSecretFileError(cause) + ? Effect.void + : Effect.fail( + new SecretStoreError({ + message: `Failed to remove secret ${name}.`, + cause, + }), + ), + ), + ); return { get, From 4394798ed6711663af7586374147dc572339414d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 21:18:10 -0700 Subject: [PATCH 06/16] Use Effect clock for session expiry verification Co-authored-by: codex --- .../auth/Layers/SessionCredentialService.test.ts | 16 +++++++++++++++- .../src/auth/Layers/SessionCredentialService.ts | 5 +++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/Layers/SessionCredentialService.test.ts b/apps/server/src/auth/Layers/SessionCredentialService.test.ts index 5c943d81ec..166a0e4626 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.test.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; @@ -41,7 +42,7 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { expect(verified.method).toBe("browser-session-cookie"); expect(verified.subject).toBe("desktop-bootstrap"); - expect(verified.expiresAt).toBe(issued.expiresAt); + expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); }).pipe(Effect.provide(makeSessionCredentialLayer())), ); it.effect("rejects malformed session tokens", () => @@ -53,4 +54,17 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { expect(error.message).toContain("Malformed session token"); }).pipe(Effect.provide(makeSessionCredentialLayer())), ); + it.effect("verifies session tokens against the Effect clock", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + method: "bearer-session-token", + subject: "test-clock", + }); + const verified = yield* sessions.verify(issued.token); + + expect(verified.method).toBe("bearer-session-token"); + expect(verified.subject).toBe("test-clock"); + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), + ); }); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index 51484615b9..ad14f3e9f6 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -1,4 +1,4 @@ -import { DateTime, Duration, Effect, Layer, Schema } from "effect"; +import { Clock, DateTime, Duration, Effect, Layer, Schema } from "effect"; import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; import { @@ -80,7 +80,8 @@ export const makeSessionCredentialService = Effect.gen(function* () { }), }); - if (claims.exp <= Date.now()) { + const now = yield* Clock.currentTimeMillis; + if (claims.exp <= now) { return yield* new SessionCredentialError({ message: "Session token expired.", }); From 342595cdaa45b7734d79ea088308d0f551e2c26e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 21:40:03 -0700 Subject: [PATCH 07/16] Stub auth HTTP for browser app suites Co-authored-by: codex --- apps/web/src/components/ChatView.browser.tsx | 2 ++ .../components/KeybindingsToast.browser.tsx | 2 ++ apps/web/test/authHttpHandlers.ts | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 apps/web/test/authHttpHandlers.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 2189978f23..ba3e505b9d 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -44,6 +44,7 @@ import { getRouter } from "../router"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; +import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; @@ -827,6 +828,7 @@ const worker = setupWorker( void rpcHarness.onMessage(rawData); }); }), + ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), http.get("*/attachments/:attachmentId", () => HttpResponse.text(ATTACHMENT_SVG, { headers: { diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index a7bbf26644..b017f2c82c 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -24,6 +24,7 @@ import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { useStore } from "../store"; +import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; vi.mock("../lib/gitStatusState", () => ({ @@ -216,6 +217,7 @@ const worker = setupWorker( void rpcHarness.onMessage(rawData); }); }), + ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), ); diff --git a/apps/web/test/authHttpHandlers.ts b/apps/web/test/authHttpHandlers.ts new file mode 100644 index 0000000000..d45dc57409 --- /dev/null +++ b/apps/web/test/authHttpHandlers.ts @@ -0,0 +1,26 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { HttpResponse, http } from "msw"; + +const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z"; +const TEST_SESSION_TOKEN = "browser-test-session-token"; + +export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) { + return [ + http.get("*/api/auth/session", () => + HttpResponse.json({ + authenticated: true, + auth: getAuthDescriptor(), + sessionMethod: "browser-session-cookie", + expiresAt: TEST_SESSION_EXPIRES_AT, + }), + ), + http.post("*/api/auth/bootstrap", () => + HttpResponse.json({ + authenticated: true, + sessionMethod: "browser-session-cookie", + sessionToken: TEST_SESSION_TOKEN, + expiresAt: TEST_SESSION_EXPIRES_AT, + }), + ), + ] as const; +} From 9b440946bed6b9355cb44266c242cef85ca99b24 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 21:41:40 -0700 Subject: [PATCH 08/16] Invalidate cached auth gate after pairing Co-authored-by: codex --- apps/web/src/authBootstrap.test.ts | 3 +++ apps/web/src/authBootstrap.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 29a145ea13..14a5c740ce 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -201,6 +201,9 @@ describe("resolveInitialServerAuthGateState", () => { }, }); await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts index 088e0a5c49..ec7bb24f7d 100644 --- a/apps/web/src/authBootstrap.ts +++ b/apps/web/src/authBootstrap.ts @@ -122,6 +122,7 @@ export async function submitServerAuthCredential(credential: string): Promise Date: Sun, 5 Apr 2026 21:53:17 -0700 Subject: [PATCH 09/16] Document server auth policies and pairing methods - Clarify auth policy, bootstrap, and session method meanings - Describe the server auth descriptor used by clients - Add guidance for pairing and steady-state auth flows - Co-authored-by: codex --- packages/contracts/src/auth.ts | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index b7312d7185..83d7345618 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -2,6 +2,29 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; +/** + * Declares the server's overall authentication posture. + * + * This is a high-level policy label that tells clients how the environment is + * expected to be accessed, not a transport detail and not an exhaustive list + * of every accepted credential. + * + * Typical usage: + * - rendered in auth/pairing UI so the user understands what kind of + * environment they are connecting to + * - used by clients to decide whether silent desktop bootstrap is expected or + * whether an explicit pairing flow should be shown + * + * Meanings: + * - `desktop-managed-local`: local desktop-managed environment with narrow + * trusted bootstrap, intended to avoid login prompts on the same machine + * - `loopback-browser`: standalone local server intended for browser pairing on + * the same machine + * - `remote-reachable`: environment intended to be reached from other devices + * or networks, where explicit pairing/auth is expected + * - `unsafe-no-auth`: intentionally unauthenticated mode; this is an explicit + * unsafe escape hatch, not a normal deployment mode + */ export const ServerAuthPolicy = Schema.Literals([ "desktop-managed-local", "loopback-browser", @@ -10,15 +33,62 @@ export const ServerAuthPolicy = Schema.Literals([ ]); export type ServerAuthPolicy = typeof ServerAuthPolicy.Type; +/** + * A credential type that can be exchanged for a real authenticated session. + * + * Bootstrap methods are for establishing trust at the start of a connection or + * pairing flow. They are not the long-lived credential used for ordinary + * authenticated HTTP / WebSocket traffic after pairing succeeds. + * + * Current methods: + * - `desktop-bootstrap`: a trusted local desktop handoff, used so the desktop + * shell can pair the renderer without a login screen + * - `one-time-token`: a short-lived pairing token, suitable for manual pairing + * flows such as `/pair?token=...` + */ export const ServerAuthBootstrapMethod = Schema.Literals(["desktop-bootstrap", "one-time-token"]); export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type; +/** + * A credential type accepted for steady-state authenticated requests after a + * client has already paired. + * + * These methods are used by the server-wide auth layer for privileged HTTP and + * WebSocket access. They are distinct from bootstrap methods so clients can + * reason clearly about "pair first, then use session auth". + * + * Current methods: + * - `browser-session-cookie`: cookie-backed browser session, used by the web + * app after bootstrap/pairing + * - `bearer-session-token`: token-based session suitable for non-cookie or + * non-browser clients + */ export const ServerAuthSessionMethod = Schema.Literals([ "browser-session-cookie", "bearer-session-token", ]); export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; +/** + * Server-advertised auth capabilities for a specific execution environment. + * + * Clients should treat this as the authoritative description of how that + * environment expects to be paired and how authenticated requests should be + * made afterward. + * + * Field meanings: + * - `policy`: high-level auth posture for the environment + * - `bootstrapMethods`: pairing/bootstrap methods the server is currently + * willing to accept + * - `sessionMethods`: authenticated request/session methods the server supports + * once pairing is complete + * - `sessionCookieName`: cookie name clients should expect when + * `browser-session-cookie` is in use + * + * This descriptor is intentionally capability-oriented. It lets clients choose + * the right UX without embedding server-specific auth logic or assuming a + * single access method. + */ export const ServerAuthDescriptor = Schema.Struct({ policy: ServerAuthPolicy, bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod), From aa33139cd8c5d6ebae01576e3d9fd549bcd65f8e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 23:50:20 -0700 Subject: [PATCH 10/16] Add remote auth pairing bootstrap flow - route auth and observability requests through the active web origin - simplify desktop startup for pairing mode and loopback backend binding - improve the boot shell while the app waits for the initial config snapshot --- apps/desktop/scripts/dev-electron.mjs | 19 ++- apps/desktop/src/main.ts | 132 ++++++++++++------ apps/server/src/auth/http.ts | 6 +- apps/server/src/serverRuntimeStartup.ts | 6 +- apps/web/index.html | 146 +++++++++++++++++++- apps/web/src/authBootstrap.test.ts | 48 +++++-- apps/web/src/authBootstrap.ts | 30 ++-- apps/web/src/components/BootShell.tsx | 64 +++++++++ apps/web/src/components/ProjectFavicon.tsx | 5 +- apps/web/src/lib/utils.test.ts | 54 +++++++- apps/web/src/lib/utils.ts | 85 +++++++++++- apps/web/src/observability/clientTracing.ts | 5 +- apps/web/src/router.ts | 1 + apps/web/src/routes/__root.tsx | 25 ++-- apps/web/src/rpc/client.ts | 12 +- apps/web/src/wsTransport.ts | 9 +- apps/web/vite.config.ts | 44 +++++- scripts/dev-runner.test.ts | 10 +- scripts/dev-runner.ts | 11 +- turbo.json | 2 +- 20 files changed, 571 insertions(+), 143 deletions(-) create mode 100644 apps/web/src/components/BootShell.tsx diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 5244d51dbf..7c0d55ac9a 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -5,8 +5,17 @@ import { join } from "node:path"; import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; -const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); -const devServerUrl = `http://localhost:${port}`; +const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); +if (!devServerUrl) { + throw new Error("VITE_DEV_SERVER_URL is required for desktop development."); +} + +const devServer = new URL(devServerUrl); +const port = Number.parseInt(devServer.port, 10); +if (!Number.isInteger(port) || port <= 0) { + throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`); +} + const requiredFiles = [ "dist-electron/main.js", "dist-electron/preload.js", @@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200; await waitForResources({ baseDir: desktopDir, files: requiredFiles, + tcpHost: devServer.hostname, tcpPort: port, }); @@ -62,10 +72,7 @@ function startApp() { [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], { cwd: desktopDir, - env: { - ...childEnv, - VITE_DEV_SERVER_URL: devServerUrl, - }, + env: childEnv, stdio: "inherit", }, ); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e651cc35a7..14eb2e3d5b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -83,6 +83,7 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; +const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -142,6 +143,28 @@ function readPersistedBackendObservabilitySettings(): { } } +function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { + if (!rawPort) { + return undefined; + } + + const parsedPort = Number.parseInt(rawPort, 10); + if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { + return undefined; + } + + return parsedPort; +} + +function resolveDesktopDevServerUrl(): string { + const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); + if (!devServerUrl) { + throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); + } + + return devServerUrl; +} + function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; @@ -199,28 +222,6 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } -function resolveDesktopBackendHost(): string { - if (!isDevelopment) { - return "127.0.0.1"; - } - - const devServerUrl = process.env.VITE_DEV_SERVER_URL; - if (!devServerUrl) { - return "127.0.0.1"; - } - - try { - const hostname = new URL(devServerUrl).hostname.trim(); - if (hostname === "localhost" || hostname === "127.0.0.1") { - return hostname; - } - } catch { - // Fall through to the default loopback host. - } - - return "127.0.0.1"; -} - async function waitForBackendHttpReady(baseUrl: string): Promise { const deadline = Date.now() + 10_000; @@ -588,10 +589,7 @@ function dispatchMenuAction(action: string): void { const send = () => { if (targetWindow.isDestroyed()) return; targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); - if (!targetWindow.isVisible()) { - targetWindow.show(); - } - targetWindow.focus(); + revealWindow(targetWindow); }; if (targetWindow.webContents.isLoadingMainFrame()) { @@ -812,6 +810,26 @@ function clearUpdatePollTimer(): void { } } +function revealWindow(window: BrowserWindow): void { + if (window.isDestroyed()) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + if (process.platform === "darwin") { + app.focus({ steal: true }); + } + + window.focus(); +} + function emitUpdateState(): void { for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed()) continue; @@ -1081,7 +1099,7 @@ function startBackend(): void { noBrowser: true, port: backendPort, t3Home: BASE_DIR, - host: resolveDesktopBackendHost(), + host: DESKTOP_LOOPBACK_HOST, desktopBootstrapToken: backendBootstrapToken, ...(backendObservabilitySettings.otlpTracesUrl ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } @@ -1393,14 +1411,19 @@ function getIconOption(): { icon: string } | Record { return iconPath ? { icon: iconPath } : {}; } +function getInitialWindowBackgroundColor(): string { + return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, height: 780, minWidth: 840, minHeight: 620, - show: false, + show: isDevelopment, autoHideMenuBar: true, + backgroundColor: getInitialWindowBackgroundColor(), ...getIconOption(), title: APP_DISPLAY_NAME, titleBarStyle: "hiddenInset", @@ -1457,13 +1480,18 @@ function createWindow(): BrowserWindow { window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); }); - window.once("ready-to-show", () => { - window.show(); - }); + if (!isDevelopment) { + window.once("ready-to-show", () => { + revealWindow(window); + }); + } if (isDevelopment) { - void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); + void window.loadURL(resolveDesktopDevServerUrl()); window.webContents.openDevTools({ mode: "detach" }); + setImmediate(() => { + revealWindow(window); + }); } else { void window.loadURL(resolveDesktopWindowUrl()); } @@ -1494,22 +1522,46 @@ configureAppIdentity(); async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap start"); - const backendHost = resolveDesktopBackendHost(); - backendPort = await Effect.service(NetService).pipe( - Effect.flatMap((net) => net.reserveLoopbackPort(backendHost)), - Effect.provide(NetService.layer), - Effect.runPromise, + const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); + if (isDevelopment && configuredBackendPort === undefined) { + throw new Error("T3CODE_PORT is required in desktop development."); + } + + backendPort = + configuredBackendPort ?? + (await Effect.service(NetService).pipe( + Effect.flatMap((net) => net.reserveLoopbackPort(DESKTOP_LOOPBACK_HOST)), + Effect.provide(NetService.layer), + Effect.runPromise, + )); + writeDesktopLogHeader( + configuredBackendPort === undefined + ? `reserved backend port via NetService port=${backendPort}` + : `using configured backend port port=${backendPort}`, ); - writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); - backendHttpUrl = `http://${backendHost}:${backendPort}`; - backendWsUrl = `ws://${backendHost}:${backendPort}`; + backendHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${backendPort}`; + backendWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${backendPort}`; writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); startBackend(); writeDesktopLogHeader("bootstrap backend start requested"); + + if (isDevelopment) { + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); + void waitForBackendHttpReady(backendHttpUrl) + .then(() => { + writeDesktopLogHeader("bootstrap backend ready"); + }) + .catch((error) => { + handleFatalStartupError("bootstrap", error); + }); + return; + } + await waitForBackendHttpReady(backendHttpUrl); writeDesktopLogHeader("bootstrap backend ready"); mainWindow = createWindow(); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 8bb1ab93be..f8efef7f0b 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -1,5 +1,5 @@ import { AuthBootstrapInput } from "@t3tools/contracts"; -import { DateTime, Effect, Schema } from "effect"; +import { DateTime, Effect } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; @@ -27,11 +27,9 @@ export const authBootstrapRouteLayer = HttpRouter.add( "POST", "/api/auth/bootstrap", Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* ServerAuth; const descriptor = yield* serverAuth.getDescriptor(); - const payload = yield* request.json.pipe( - Effect.flatMap((body) => Schema.decodeUnknownEffect(AuthBootstrapInput)(body)), + const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( Effect.mapError( (cause) => new AuthError({ diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 427b2e3902..ab139c2adc 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -385,9 +385,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: browser open check"); const startupBrowserTarget = yield* resolveStartupBrowserTarget; if (serverConfig.mode !== "desktop") { - yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.", { - pairingUrl: startupBrowserTarget, - }); + yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe( + Effect.annotateLogs({ pairingUrl: startupBrowserTarget }), + ); } yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); yield* Effect.logDebug("startup phase: complete"); diff --git a/apps/web/index.html b/apps/web/index.html index 1223458627..1e9fb62b3f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -67,10 +67,10 @@ } #boot-shell-card { - width: min(100%, 32rem); - border-radius: 20px; + width: min(100%, 36rem); + border-radius: 28px; border: 1px solid rgba(24, 24, 27, 0.08); - background: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.92); padding: 24px; box-shadow: 0 20px 70px rgba(15, 23, 42, 0.12); backdrop-filter: blur(18px); @@ -82,6 +82,13 @@ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); } + #boot-shell-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + } + #boot-shell-eyebrow { margin: 0; font-size: 11px; @@ -104,6 +111,109 @@ line-height: 1.55; opacity: 0.72; } + + #boot-shell-icon { + display: flex; + align-items: center; + justify-content: center; + border-radius: 16px; + border: 1px solid rgba(24, 24, 27, 0.08); + background: rgba(255, 255, 255, 0.8); + padding: 12px; + box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06); + } + + html.dark #boot-shell-icon { + border-color: rgba(255, 255, 255, 0.08); + background: rgba(10, 10, 10, 0.8); + } + + #boot-shell-spinner { + width: 20px; + height: 20px; + border-radius: 999px; + border: 2px solid rgba(113, 113, 122, 0.32); + border-top-color: currentColor; + animation: boot-shell-spin 0.9s linear infinite; + } + + #boot-shell-status { + display: grid; + gap: 12px; + margin-top: 20px; + border-radius: 18px; + border: 1px solid rgba(24, 24, 27, 0.08); + background: rgba(255, 255, 255, 0.6); + padding: 16px; + } + + html.dark #boot-shell-status { + border-color: rgba(255, 255, 255, 0.08); + background: rgba(10, 10, 10, 0.6); + } + + @media (min-width: 640px) { + #boot-shell-status { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + .boot-shell-status-label { + margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + opacity: 0.72; + } + + .boot-shell-status-value { + margin: 4px 0 0; + font-size: 15px; + font-weight: 600; + } + + #boot-shell-actions { + display: flex; + gap: 8px; + margin-top: 20px; + } + + #boot-shell-button, + #boot-shell-details { + border-radius: 10px; + font: inherit; + } + + #boot-shell-button { + border: 0; + background: #2563eb; + color: white; + padding: 10px 14px; + font-size: 14px; + font-weight: 600; + } + + #boot-shell-details { + margin-top: 20px; + border: 1px solid rgba(24, 24, 27, 0.08); + background: rgba(255, 255, 255, 0.55); + padding: 10px 12px; + font-size: 12px; + font-weight: 500; + opacity: 0.72; + } + + html.dark #boot-shell-details { + border-color: rgba(255, 255, 255, 0.08); + background: rgba(10, 10, 10, 0.55); + } + + @keyframes boot-shell-spin { + to { + transform: rotate(360deg); + } + } @@ -118,11 +228,35 @@
-

T3 Code (Alpha)

-

Connecting to T3 Server

+
+
+

Starting Session

+

Connecting to T3 Code (Alpha)

+
+ +

- Opening the local session and waiting for the app shell to load. + Opening the WebSocket connection to the T3 Code (Alpha) server and waiting for the + initial config snapshot.

+
+
+

Connection

+

Opening WebSocket

+
+
+

Latest Event

+

Pending

+
+
+
+ +
+
Show connection details
diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 14a5c740ce..509ce34edb 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -85,12 +85,8 @@ describe("resolveInitialServerAuthGateState", () => { await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls[0]?.[0]).toEqual( - new URL("/api/auth/session", "http://localhost:3773/"), - ); - expect(fetchMock.mock.calls[1]?.[0]).toEqual( - new URL("/api/auth/bootstrap", "http://localhost:3773/"), - ); + expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost/api/auth/session"); + expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost/api/auth/bootstrap"); }); it("uses https fetch urls when the primary environment uses wss", async () => { @@ -120,12 +116,42 @@ describe("resolveInitialServerAuthGateState", () => { }, }); - expect(fetchMock).toHaveBeenCalledWith( - new URL("/api/auth/session", "https://remote.example.com/ws"), - { - credentials: "include", - }, + expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/session", { + credentials: "include", + }); + }); + + it("uses the current origin as an auth proxy base for local dev environments", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), ); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3773/ws"); + installTestBrowser("http://localhost:5735/"); + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + + expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/api/auth/session", { + credentials: "include", + }); }); it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts index ec7bb24f7d..d4d4059d87 100644 --- a/apps/web/src/authBootstrap.ts +++ b/apps/web/src/authBootstrap.ts @@ -1,7 +1,5 @@ import type { AuthBootstrapInput, AuthBootstrapResult, AuthSessionState } from "@t3tools/contracts"; -import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; - -import { getPrimaryKnownEnvironment } from "./environmentBootstrap"; +import { resolveServerHttpUrl } from "./lib/utils"; export type ServerAuthGateState = | { status: "authenticated" } @@ -48,16 +46,8 @@ function getDesktopBootstrapCredential(): string | null { : null; } -function resolvePrimaryEnvironmentHttpBaseUrl(): string { - const baseUrl = getKnownEnvironmentHttpBaseUrl(getPrimaryKnownEnvironment()); - if (!baseUrl) { - throw new Error("Unable to resolve a known environment bootstrap URL."); - } - return baseUrl; -} - -async function fetchSessionState(baseUrl: string): Promise { - const response = await fetch(new URL("/api/auth/session", baseUrl), { +async function fetchSessionState(): Promise { + const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/session" }), { credentials: "include", }); if (!response.ok) { @@ -66,12 +56,9 @@ async function fetchSessionState(baseUrl: string): Promise { return (await response.json()) as AuthSessionState; } -async function exchangeBootstrapCredential( - baseUrl: string, - credential: string, -): Promise { +async function exchangeBootstrapCredential(credential: string): Promise { const payload: AuthBootstrapInput = { credential }; - const response = await fetch(new URL("/api/auth/bootstrap", baseUrl), { + const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/bootstrap" }), { body: JSON.stringify(payload), credentials: "include", headers: { @@ -89,9 +76,8 @@ async function exchangeBootstrapCredential( } async function bootstrapServerAuth(): Promise { - const baseUrl = resolvePrimaryEnvironmentHttpBaseUrl(); const bootstrapCredential = getBootstrapCredential(); - const currentSession = await fetchSessionState(baseUrl); + const currentSession = await fetchSessionState(); if (currentSession.authenticated) { return { status: "authenticated" }; } @@ -104,7 +90,7 @@ async function bootstrapServerAuth(): Promise { } try { - await exchangeBootstrapCredential(baseUrl, bootstrapCredential); + await exchangeBootstrapCredential(bootstrapCredential); return { status: "authenticated" }; } catch (error) { return { @@ -121,7 +107,7 @@ export async function submitServerAuthCredential(credential: string): Promise +
+
+
+
+ +
+
+
+

+ {eyebrow} +

+

{title}

+
+ +
+
+
+
+ +

{copy}

+ +
+
+

+ Connection +

+

{connectionLabel}

+
+
+

+ Latest Event +

+

{latestEventLabel}

+
+
+ +
+
+ Reload app +
+
+ +
+ Show connection details +
+
+
+ ); +} diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 58426f50ba..093739d6c5 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,12 +1,11 @@ import { FolderIcon } from "lucide-react"; import { useState } from "react"; -import { resolveServerUrl } from "~/lib/utils"; +import { resolveServerHttpUrl } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Set(); export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) { - const src = resolveServerUrl({ - protocol: "http", + const src = resolveServerHttpUrl({ pathname: "/api/project-favicon", searchParams: { cwd }, }); diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 317933d677..57d2cce4f9 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,4 +1,4 @@ -import { assert, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, assert, expect, it, vi } from "vitest"; const { resolvePrimaryEnvironmentBootstrapUrlMock } = vi.hoisted(() => ({ resolvePrimaryEnvironmentBootstrapUrlMock: vi.fn(() => "http://bootstrap.test:4321"), @@ -8,8 +8,7 @@ vi.mock("../environmentBootstrap", () => ({ resolvePrimaryEnvironmentBootstrapUrl: resolvePrimaryEnvironmentBootstrapUrlMock, })); -import { isWindowsPlatform } from "./utils"; -import { resolveServerUrl } from "./utils"; +import { isWindowsPlatform, resolveServerHttpUrl, resolveServerUrl } from "./utils"; describe("isWindowsPlatform", () => { it("matches Windows platform identifiers", () => { @@ -23,6 +22,43 @@ describe("isWindowsPlatform", () => { }); }); +const originalWindow = globalThis.window; + +beforeEach(() => { + resolvePrimaryEnvironmentBootstrapUrlMock.mockReset(); + resolvePrimaryEnvironmentBootstrapUrlMock.mockReturnValue("http://bootstrap.test:4321"); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + origin: "http://localhost:5735", + hostname: "localhost", + port: "5735", + protocol: "http:", + }, + }, + }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + }); +}); + +describe("resolveServerHttpUrl", () => { + it("uses the Vite dev origin for local HTTP requests automatically", () => { + vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3775/ws"); + + assert.equal( + resolveServerHttpUrl({ pathname: "/api/observability/v1/traces" }), + "http://localhost:5735/api/observability/v1/traces", + ); + }); +}); + describe("resolveServerUrl", () => { it("falls back to the bootstrap environment URL when the explicit URL is empty", () => { expect(resolveServerUrl({ url: "" })).toBe("http://bootstrap.test:4321/"); @@ -52,4 +88,16 @@ describe("resolveServerUrl", () => { "https://override.test:9999/", ); }); + + it("keeps the backend origin for websocket requests", () => { + vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3775/ws"); + + assert.equal( + resolveServerUrl({ + protocol: "ws", + pathname: "/ws", + }), + "ws://127.0.0.1:3775/ws", + ); + }); }); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 27800b3a61..183735d340 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -48,11 +48,8 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = isNonEmptyString(options?.url) - ? options.url - : resolvePrimaryEnvironmentBootstrapUrl(); - - const parsedUrl = new URL(rawUrl); + const rawUrl = resolveBaseServerUrl(options?.url); + const parsedUrl = resolveServerBaseUrl(rawUrl, options?.protocol); if (options?.protocol) { parsedUrl.protocol = options.protocol; } @@ -66,3 +63,81 @@ export const resolveServerUrl = (options?: { } return parsedUrl.toString(); }; + +export const resolveServerHttpUrl = (options?: { + url?: string | undefined; + pathname?: string | undefined; + searchParams?: Record | undefined; +}): string => { + const rawUrl = resolveBaseServerUrl(options?.url); + return resolveServerUrl({ + ...options, + url: rawUrl, + protocol: inferHttpProtocol(rawUrl), + }); +}; + +function resolveBaseServerUrl(url?: string | undefined): string { + if (isNonEmptyString(url)) { + return url; + } + + const bootstrapUrl = resolvePrimaryEnvironmentBootstrapUrl(); + if (isNonEmptyString(bootstrapUrl)) { + return bootstrapUrl; + } + + return firstNonEmptyString(import.meta.env.VITE_WS_URL, window.location.origin); +} + +function resolveServerBaseUrl( + rawUrl: string, + requestedProtocol: "http" | "https" | "ws" | "wss" | undefined, +): URL { + const currentUrl = new URL(window.location.origin); + const targetUrl = new URL(rawUrl, currentUrl); + + if (shouldUseSameOriginForLocalHttp(currentUrl, targetUrl, requestedProtocol)) { + return new URL(currentUrl); + } + + return targetUrl; +} + +function shouldUseSameOriginForLocalHttp( + currentUrl: URL, + targetUrl: URL, + requestedProtocol: "http" | "https" | "ws" | "wss" | undefined, +): boolean { + const protocol = requestedProtocol ?? targetUrl.protocol.slice(0, -1); + if (protocol !== "http" && protocol !== "https") { + return false; + } + + try { + return ( + isLocalHostname(currentUrl.hostname) && + isLocalHostname(targetUrl.hostname) && + currentUrl.origin !== targetUrl.origin + ); + } catch { + return false; + } +} + +function inferHttpProtocol(rawUrl: string): "http" | "https" { + try { + const url = new URL(rawUrl, window.location.origin); + if (url.protocol === "wss:" || url.protocol === "https:") { + return "https"; + } + } catch { + // Fall back to http for malformed values. + } + + return "http"; +} + +function isLocalHostname(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; +} diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index aa9d50c114..c631292f0e 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -3,7 +3,7 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { isElectron } from "../env"; -import { resolveServerUrl } from "../lib/utils"; +import { resolveServerHttpUrl } from "../lib/utils"; import { APP_VERSION } from "~/branding"; const DEFAULT_EXPORT_INTERVAL_MS = 1_000; @@ -51,8 +51,7 @@ export function configureClientTracing(config: ClientTracingConfig = {}): Promis } async function applyClientTracingConfig(config: ClientTracingConfig): Promise { - const otlpTracesUrl = resolveServerUrl({ - protocol: window.location.protocol === "https:" ? "https" : "http", + const otlpTracesUrl = resolveServerHttpUrl({ pathname: "/api/observability/v1/traces", }); const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS); diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 84beaf9fc4..971ae16eee 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -11,6 +11,7 @@ export function getRouter(history: RouterHistory) { return createRouter({ routeTree, history, + defaultPendingMs: 0, context: { queryClient, }, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4d3c0c7da6..c45a5fae07 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -23,6 +23,7 @@ import { Throttler } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; +import { BootShell } from "../components/BootShell"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, @@ -69,6 +70,7 @@ import { type WsRpcClientEntry, } from "~/wsRpcClient"; import { resolveInitialServerAuthGateState } from "../authBootstrap"; +import { configureClientTracing } from "../observability/clientTracing"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -88,10 +90,6 @@ function RootRouteView() { const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); - if (!authGateState) { - return ; - } - if (pathname === "/pair") { return ; } @@ -107,6 +105,7 @@ function RootRouteView() { return ( + @@ -123,11 +122,11 @@ function RootRouteView() { function RootRoutePendingView() { return ( -
-
-

Connecting to {APP_DISPLAY_NAME} server...

-
-
+ ); } @@ -257,6 +256,14 @@ function ServerStateBootstrap() { return null; } +function AuthenticatedTracingBootstrap() { + useEffect(() => { + void configureClientTracing(); + }, []); + + return null; +} + function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); diff --git a/apps/web/src/rpc/client.ts b/apps/web/src/rpc/client.ts index e4f9a6bbdd..2b0e8feb5c 100644 --- a/apps/web/src/rpc/client.ts +++ b/apps/web/src/rpc/client.ts @@ -2,11 +2,7 @@ import { WsRpcGroup } from "@t3tools/contracts"; import { Effect, Layer, ManagedRuntime } from "effect"; import { AtomRpc } from "effect/unstable/reactivity"; -import { - __resetClientTracingForTests, - ClientTracingLive, - configureClientTracing, -} from "../observability/clientTracing"; +import { __resetClientTracingForTests, ClientTracingLive } from "../observability/clientTracing"; import { createWsRpcProtocolLayer } from "./protocol"; export class WsRpcAtomClient extends AtomRpc.Service()("WsRpcAtomClient", { @@ -28,10 +24,8 @@ function getRuntime() { export function runRpc( execute: (client: typeof WsRpcAtomClient.Service) => Effect.Effect, ): Promise { - return configureClientTracing().then(() => { - const runtime = getRuntime(); - return runtime.runPromise(WsRpcAtomClient.use(execute)); - }); + const runtime = getRuntime(); + return runtime.runPromise(WsRpcAtomClient.use(execute)); } export async function __resetWsRpcAtomClientForTests() { diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 3e435ee167..4af7835b81 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -11,7 +11,7 @@ import { } from "effect"; import { RpcClient } from "effect/unstable/rpc"; -import { ClientTracingLive, configureClientTracing } from "./observability/clientTracing"; +import { ClientTracingLive } from "./observability/clientTracing"; import { createWsRpcProtocolLayer, makeWsRpcProtocolClient, @@ -44,7 +44,6 @@ function formatErrorMessage(error: unknown): string { } export class WsTransport { - private readonly tracingReady: Promise; private readonly url: string | undefined; private disposed = false; private reconnectChain: Promise = Promise.resolve(); @@ -52,7 +51,6 @@ export class WsTransport { constructor(url?: string) { this.url = url; - this.tracingReady = configureClientTracing(); this.session = this.createSession(); } @@ -64,7 +62,6 @@ export class WsTransport { throw new Error("Transport disposed"); } - await this.tracingReady; const session = this.session; const client = await session.clientPromise; return await session.runtime.runPromise(Effect.suspend(() => execute(client))); @@ -78,7 +75,6 @@ export class WsTransport { throw new Error("Transport disposed"); } - await this.tracingReady; const session = this.session; const client = await session.clientPromise; await session.runtime.runPromise( @@ -220,8 +216,7 @@ export class WsTransport { rejectCompleted = reject; }); const cancel = session.runtime.runCallback( - Effect.promise(() => this.tracingReady).pipe( - Effect.flatMap(() => Effect.promise(() => session.clientPromise)), + Effect.promise(() => session.clientPromise).pipe( Effect.flatMap((client) => Stream.runForEach(connect(client), (value) => Effect.sync(() => { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 56b138d331..a9d47df60e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -6,6 +6,8 @@ import { defineConfig } from "vite"; import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); +const host = process.env.HOST?.trim() || "localhost"; +const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); const buildSourcemap = @@ -15,6 +17,29 @@ const buildSourcemap = ? "hidden" : true; +function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { + if (!wsUrl) { + return undefined; + } + + try { + const url = new URL(wsUrl); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + url.pathname = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return undefined; + } +} + +const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); + export default defineConfig({ plugins: [ tanstackRouter(), @@ -34,21 +59,36 @@ export default defineConfig({ }, define: { // In dev mode, tell the web app where the WebSocket server lives - "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""), + "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), }, resolve: { tsconfigPaths: true, }, server: { + host, port, strictPort: true, + ...(devProxyTarget + ? { + proxy: { + "/api": { + target: devProxyTarget, + changeOrigin: true, + }, + "/attachments": { + target: devProxyTarget, + changeOrigin: true, + }, + }, + } + : {}), hmr: { // Explicit config so Vite's HMR WebSocket connects reliably // inside Electron's BrowserWindow. Vite 8 uses console.debug for // connection logs — enable "Verbose" in DevTools to see them. protocol: "ws", - host: "localhost", + host, }, }, build: { diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 8005c00107..fc4a7b5733 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -156,7 +156,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - it.effect("does not export backend bootstrap env for dev:desktop", () => + it.effect("pins desktop dev to a stable backend port and websocket url", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ mode: "dev:desktop", @@ -180,13 +180,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); assert.equal(env.PORT, "5733"); - assert.equal(env.ELECTRON_RENDERER_PORT, "5733"); - assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:5733"); - assert.equal(env.T3CODE_PORT, undefined); + assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); + assert.equal(env.HOST, "127.0.0.1"); + assert.equal(env.T3CODE_PORT, "4222"); assert.equal(env.T3CODE_MODE, undefined); assert.equal(env.T3CODE_NO_BROWSER, undefined); assert.equal(env.T3CODE_HOST, undefined); - assert.equal(env.VITE_WS_URL, undefined); + assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); }), ); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 7089450e89..b50c8237f6 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -13,6 +13,7 @@ const BASE_SERVER_PORT = 3773; const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; +const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => path.join(homedir(), ".t3"), @@ -150,8 +151,9 @@ export function createDevRunnerEnv({ const output: NodeJS.ProcessEnv = { ...baseEnv, PORT: String(webPort), - ELECTRON_RENDERER_PORT: String(webPort), - VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, + VITE_DEV_SERVER_URL: + devUrl?.toString() ?? + `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, T3CODE_HOME: resolvedBaseDir, }; @@ -159,8 +161,8 @@ export function createDevRunnerEnv({ output.T3CODE_PORT = String(serverPort); output.VITE_WS_URL = `ws://localhost:${serverPort}`; } else { - delete output.T3CODE_PORT; - delete output.VITE_WS_URL; + output.T3CODE_PORT = String(serverPort); + output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; delete output.T3CODE_MODE; delete output.T3CODE_NO_BROWSER; delete output.T3CODE_HOST; @@ -199,6 +201,7 @@ export function createDevRunnerEnv({ } if (isDesktopMode) { + output.HOST = DESKTOP_DEV_LOOPBACK_HOST; delete output.T3CODE_DESKTOP_WS_URL; } diff --git a/turbo.json b/turbo.json index 47d7091385..d4d58c15e6 100644 --- a/turbo.json +++ b/turbo.json @@ -1,10 +1,10 @@ { "$schema": "https://turbo.build/schema.json", "globalEnv": [ + "HOST", "PORT", "VITE_WS_URL", "VITE_DEV_SERVER_URL", - "ELECTRON_RENDERER_PORT", "T3CODE_LOG_WS_EVENTS", "T3CODE_MODE", "T3CODE_PORT", From f0d6fce903a104fcac7ebb5e0b766077e6b07d41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 00:14:57 -0700 Subject: [PATCH 11/16] Simplify startup and pairing bootstrap - Replace the verbose boot shell with a minimal splash screen - Move pairing loading UI into the /pair route pending state - Gate the main app on bootstrap completion instead of auth preload --- apps/web/index.html | 192 +----------------- apps/web/src/components/BootShell.tsx | 64 ------ apps/web/src/components/SplashScreen.tsx | 9 + .../components/WebSocketConnectionSurface.tsx | 176 +--------------- .../components/auth/PairingRouteSurface.tsx | 81 +++++--- apps/web/src/router.ts | 1 - apps/web/src/routes/__root.tsx | 26 +-- apps/web/src/routes/pair.tsx | 7 +- 8 files changed, 84 insertions(+), 472 deletions(-) delete mode 100644 apps/web/src/components/BootShell.tsx create mode 100644 apps/web/src/components/SplashScreen.tsx diff --git a/apps/web/index.html b/apps/web/index.html index 1e9fb62b3f..33c3c26c40 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -54,165 +54,21 @@ min-height: 100%; align-items: center; justify-content: center; - padding: 24px; - background: - radial-gradient(44rem 16rem at top, rgba(59, 130, 246, 0.08), transparent), - linear-gradient(145deg, rgba(250, 250, 250, 0.96) 0%, rgba(255, 255, 255, 1) 55%); - } - - html.dark #boot-shell { - background: - radial-gradient(44rem 16rem at top, rgba(59, 130, 246, 0.12), transparent), - linear-gradient(145deg, rgba(10, 10, 10, 0.98) 0%, rgba(18, 18, 18, 1) 55%); + background: inherit; } #boot-shell-card { - width: min(100%, 36rem); - border-radius: 28px; - border: 1px solid rgba(24, 24, 27, 0.08); - background: rgba(255, 255, 255, 0.92); - padding: 24px; - box-shadow: 0 20px 70px rgba(15, 23, 42, 0.12); - backdrop-filter: blur(18px); - } - - html.dark #boot-shell-card { - border-color: rgba(255, 255, 255, 0.08); - background: rgba(24, 24, 27, 0.9); - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); - } - - #boot-shell-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - } - - #boot-shell-eyebrow { - margin: 0; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.18em; - text-transform: uppercase; - opacity: 0.72; - } - - #boot-shell-title { - margin: 12px 0 0; - font-size: clamp(1.5rem, 2vw, 2rem); - font-weight: 700; - letter-spacing: -0.03em; - } - - #boot-shell-copy { - margin: 10px 0 0; - font-size: 0.92rem; - line-height: 1.55; - opacity: 0.72; - } - - #boot-shell-icon { display: flex; align-items: center; justify-content: center; - border-radius: 16px; - border: 1px solid rgba(24, 24, 27, 0.08); - background: rgba(255, 255, 255, 0.8); - padding: 12px; - box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06); - } - - html.dark #boot-shell-icon { - border-color: rgba(255, 255, 255, 0.08); - background: rgba(10, 10, 10, 0.8); - } - - #boot-shell-spinner { - width: 20px; - height: 20px; - border-radius: 999px; - border: 2px solid rgba(113, 113, 122, 0.32); - border-top-color: currentColor; - animation: boot-shell-spin 0.9s linear infinite; - } - - #boot-shell-status { - display: grid; - gap: 12px; - margin-top: 20px; - border-radius: 18px; - border: 1px solid rgba(24, 24, 27, 0.08); - background: rgba(255, 255, 255, 0.6); - padding: 16px; + width: 96px; + height: 96px; } - html.dark #boot-shell-status { - border-color: rgba(255, 255, 255, 0.08); - background: rgba(10, 10, 10, 0.6); - } - - @media (min-width: 640px) { - #boot-shell-status { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - - .boot-shell-status-label { - margin: 0; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; - opacity: 0.72; - } - - .boot-shell-status-value { - margin: 4px 0 0; - font-size: 15px; - font-weight: 600; - } - - #boot-shell-actions { - display: flex; - gap: 8px; - margin-top: 20px; - } - - #boot-shell-button, - #boot-shell-details { - border-radius: 10px; - font: inherit; - } - - #boot-shell-button { - border: 0; - background: #2563eb; - color: white; - padding: 10px 14px; - font-size: 14px; - font-weight: 600; - } - - #boot-shell-details { - margin-top: 20px; - border: 1px solid rgba(24, 24, 27, 0.08); - background: rgba(255, 255, 255, 0.55); - padding: 10px 12px; - font-size: 12px; - font-weight: 500; - opacity: 0.72; - } - - html.dark #boot-shell-details { - border-color: rgba(255, 255, 255, 0.08); - background: rgba(10, 10, 10, 0.55); - } - - @keyframes boot-shell-spin { - to { - transform: rotate(360deg); - } + #boot-shell-logo { + width: 64px; + height: 64px; + object-fit: contain; } @@ -227,37 +83,9 @@
-
-
-
-

Starting Session

-

Connecting to T3 Code (Alpha)

-
- -
-

- Opening the WebSocket connection to the T3 Code (Alpha) server and waiting for the - initial config snapshot. -

-
-
-

Connection

-

Opening WebSocket

-
-
-

Latest Event

-

Pending

-
-
-
- -
-
Show connection details
-
+
+ +
diff --git a/apps/web/src/components/BootShell.tsx b/apps/web/src/components/BootShell.tsx deleted file mode 100644 index 8983fba099..0000000000 --- a/apps/web/src/components/BootShell.tsx +++ /dev/null @@ -1,64 +0,0 @@ -export function BootShell({ - eyebrow, - title, - copy, - connectionLabel = "Opening WebSocket", - latestEventLabel = "Pending", -}: { - eyebrow: string; - title: string; - copy: string; - connectionLabel?: string; - latestEventLabel?: string; -}) { - return ( -
-
-
-
-
- -
-
-
-

- {eyebrow} -

-

{title}

-
- -
-
-
-
- -

{copy}

- -
-
-

- Connection -

-

{connectionLabel}

-
-
-

- Latest Event -

-

{latestEventLabel}

-
-
- -
-
- Reload app -
-
- -
- Show connection details -
-
-
- ); -} diff --git a/apps/web/src/components/SplashScreen.tsx b/apps/web/src/components/SplashScreen.tsx new file mode 100644 index 0000000000..a0b593a950 --- /dev/null +++ b/apps/web/src/components/SplashScreen.tsx @@ -0,0 +1,9 @@ +export function SplashScreen() { + return ( +
+
+ T3 Code +
+
+ ); +} diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index 1855046a62..c07f59bb9d 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -1,9 +1,6 @@ -import { AlertTriangle, CloudOff, LoaderCircle, RotateCw } from "lucide-react"; import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; -import { APP_DISPLAY_NAME } from "../branding"; import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; -import { useServerConfig } from "../rpc/serverState"; import { exhaustWsReconnectIfStillWaiting, getWsConnectionStatus, @@ -14,7 +11,6 @@ import { useWsConnectionStatus, WS_RECONNECT_MAX_ATTEMPTS, } from "../rpc/wsConnectionState"; -import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; import { getWsRpcClient } from "~/wsRpcClient"; @@ -58,11 +54,7 @@ function describeExhaustedToast(): string { return "Retries exhausted trying to reconnect"; } -function buildReconnectTitle(status: WsConnectionStatus): string { - if (status.nextRetryAt === null) { - return "Disconnected from T3 Server"; - } - +function buildReconnectTitle(_status: WsConnectionStatus): string { return "Disconnected from T3 Server"; } @@ -113,155 +105,6 @@ export function shouldAutoReconnect( ); } -function buildBlockingCopy( - uiState: WsConnectionUiState, - status: WsConnectionStatus, -): { - readonly description: string; - readonly eyebrow: string; - readonly title: string; -} { - if (uiState === "connecting") { - return { - description: `Opening the WebSocket connection to the ${APP_DISPLAY_NAME} server and waiting for the initial config snapshot.`, - eyebrow: "Starting Session", - title: `Connecting to ${APP_DISPLAY_NAME}`, - }; - } - - if (uiState === "offline") { - return { - description: - "Your browser is offline, so the web client cannot reach the T3 server. Reconnect to the network and the app will retry automatically.", - eyebrow: "Offline", - title: "WebSocket connection unavailable", - }; - } - - if (status.lastError?.trim()) { - return { - description: `${status.lastError} Verify that the T3 server is running and reachable, then reload the app if needed.`, - eyebrow: "Connection Error", - title: "Cannot reach the T3 server", - }; - } - - return { - description: - "The web client could not complete its initial WebSocket connection to the T3 server. It will keep retrying in the background.", - eyebrow: "Connection Error", - title: "Cannot reach the T3 server", - }; -} - -function buildConnectionDetails(status: WsConnectionStatus, uiState: WsConnectionUiState): string { - const details = [ - `state: ${uiState}`, - `online: ${status.online ? "yes" : "no"}`, - `attempts: ${status.attemptCount}`, - ]; - - if (status.socketUrl) { - details.push(`socket: ${status.socketUrl}`); - } - if (status.connectedAt) { - details.push(`connectedAt: ${status.connectedAt}`); - } - if (status.disconnectedAt) { - details.push(`disconnectedAt: ${status.disconnectedAt}`); - } - if (status.lastErrorAt) { - details.push(`lastErrorAt: ${status.lastErrorAt}`); - } - if (status.lastError) { - details.push(`lastError: ${status.lastError}`); - } - if (status.closeCode !== null) { - details.push(`closeCode: ${status.closeCode}`); - } - if (status.closeReason) { - details.push(`closeReason: ${status.closeReason}`); - } - - return details.join("\n"); -} - -function WebSocketBlockingState({ - status, - uiState, -}: { - readonly status: WsConnectionStatus; - readonly uiState: WsConnectionUiState; -}) { - const copy = buildBlockingCopy(uiState, status); - const disconnectedAt = formatConnectionMoment(status.disconnectedAt ?? status.lastErrorAt); - const Icon = - uiState === "connecting" ? LoaderCircle : uiState === "offline" ? CloudOff : AlertTriangle; - - return ( -
-
-
-
-
- -
-
-
-

- {copy.eyebrow} -

-

{copy.title}

-
-
- -
-
- -

{copy.description}

- -
-
-

- Connection -

-

- {uiState === "connecting" - ? "Opening WebSocket" - : uiState === "offline" - ? "Waiting for network" - : "Retrying server connection"} -

-
-
-

- Latest Event -

-

{disconnectedAt ?? "Pending"}

-
-
- -
- -
- -
- - Show connection details - Hide connection details - -
-            {buildConnectionDetails(status, uiState)}
-          
-
-
-
- ); -} - export function WebSocketConnectionCoordinator() { const status = useWsConnectionStatus(); const [nowMs, setNowMs] = useState(() => Date.now()); @@ -525,20 +368,3 @@ export function SlowRpcAckToastCoordinator() { return null; } - -export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { - const serverConfig = useServerConfig(); - const status = useWsConnectionStatus(); - - if (serverConfig === null) { - const uiState = getWsConnectionUiState(status); - return ( - - ); - } - - return children; -} diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index 477f1ddeb4..f404ae9e53 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -1,12 +1,5 @@ import type { AuthSessionState } from "@t3tools/contracts"; -import { - startTransition, - type FormEvent, - useEffect, - useEffectEvent, - useRef, - useState, -} from "react"; +import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; import { APP_DISPLAY_NAME } from "../../branding"; import { @@ -17,6 +10,30 @@ import { import { Button } from "../ui/button"; import { Input } from "../ui/input"; +export function PairingPendingSurface() { + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ Pairing with this environment +

+

+ Validating the pairing link and preparing your session. +

+
+
+ ); +} + export function PairingRouteSurface({ auth, initialErrorMessage, @@ -32,31 +49,37 @@ export function PairingRouteSurface({ const [isSubmitting, setIsSubmitting] = useState(false); const autoSubmitAttemptedRef = useRef(false); - const submitCredential = useEffectEvent(async (nextCredential: string) => { - setIsSubmitting(true); - setErrorMessage(""); + const submitCredential = useCallback( + async (nextCredential: string) => { + setIsSubmitting(true); + setErrorMessage(""); - const submitError = await submitServerAuthCredential(nextCredential).then( - () => null, - (error) => errorMessageFromUnknown(error), - ); + const submitError = await submitServerAuthCredential(nextCredential).then( + () => null, + (error) => errorMessageFromUnknown(error), + ); - setIsSubmitting(false); + setIsSubmitting(false); - if (submitError) { - setErrorMessage(submitError); - return; - } + if (submitError) { + setErrorMessage(submitError); + return; + } - startTransition(() => { - onAuthenticated(); - }); - }); + startTransition(() => { + onAuthenticated(); + }); + }, + [onAuthenticated], + ); - const handleSubmit = useEffectEvent(async (event?: FormEvent) => { - event?.preventDefault(); - await submitCredential(credential); - }); + const handleSubmit = useCallback( + async (event?: React.SubmitEvent) => { + event?.preventDefault(); + await submitCredential(credential); + }, + [submitCredential, credential], + ); useEffect(() => { const token = autoPairTokenRef.current; @@ -67,7 +90,7 @@ export function PairingRouteSurface({ autoSubmitAttemptedRef.current = true; stripPairingTokenFromUrl(); void submitCredential(token); - }, []); + }, [submitCredential]); return (
diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 971ae16eee..84beaf9fc4 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -11,7 +11,6 @@ export function getRouter(history: RouterHistory) { return createRouter({ routeTree, history, - defaultPendingMs: 0, context: { queryClient, }, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c45a5fae07..a06ea21ff4 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -23,11 +23,10 @@ import { Throttler } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; -import { BootShell } from "../components/BootShell"; +import { SplashScreen } from "../components/SplashScreen"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, - WebSocketConnectionSurface, } from "../components/WebSocketConnectionSurface"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; @@ -80,7 +79,6 @@ export const Route = createRootRouteWithContext<{ }), component: RootRouteView, errorComponent: RootRouteErrorView, - pendingComponent: RootRoutePendingView, head: () => ({ meta: [{ name: "title", content: APP_DISPLAY_NAME }], }), @@ -88,6 +86,7 @@ export const Route = createRootRouteWithContext<{ function RootRouteView() { const pathname = useLocation({ select: (location) => location.pathname }); + const bootstrapComplete = useStore((store) => store.bootstrapComplete); const { authGateState } = Route.useRouteContext(); if (pathname === "/pair") { @@ -97,11 +96,6 @@ function RootRouteView() { if (authGateState.status !== "authenticated") { return ; } - - if (!readLocalApi()) { - return ; - } - return ( @@ -110,26 +104,18 @@ function RootRouteView() { - + {bootstrapComplete ? ( - + ) : ( + + )} ); } -function RootRoutePendingView() { - return ( - - ); -} - function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx index 6e30ed00d0..7cc1ce7762 100644 --- a/apps/web/src/routes/pair.tsx +++ b/apps/web/src/routes/pair.tsx @@ -1,6 +1,6 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; -import { PairingRouteSurface } from "../components/auth/PairingRouteSurface"; +import { PairingPendingSurface, PairingRouteSurface } from "../components/auth/PairingRouteSurface"; import { resolveInitialServerAuthGateState } from "../authBootstrap"; export const Route = createFileRoute("/pair")({ @@ -14,6 +14,7 @@ export const Route = createFileRoute("/pair")({ }; }, component: PairRouteView, + pendingComponent: PairRoutePendingView, }); function PairRouteView() { @@ -34,3 +35,7 @@ function PairRouteView() { /> ); } + +function PairRoutePendingView() { + return ; +} From 133b99b52da258b68aa3f1a6e5c28ad0b6225225 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 08:33:07 -0700 Subject: [PATCH 12/16] Harden auth bootstrap for remote pairing - Switch bootstrap flow to cookie-based session auth - Enforce restrictive permissions on stored secrets - Add coverage for remote-reachable auth policy and URL token rejection --- apps/server/src/auth/Layers/ServerAuth.ts | 30 ++- .../src/auth/Layers/ServerAuthPolicy.test.ts | 94 +++++++++ .../src/auth/Layers/ServerAuthPolicy.ts | 45 +++-- .../src/auth/Layers/ServerSecretStore.test.ts | 47 ++++- .../src/auth/Layers/ServerSecretStore.ts | 11 ++ apps/server/src/auth/Services/ServerAuth.ts | 10 +- apps/server/src/auth/http.ts | 4 +- apps/server/src/server.test.ts | 178 +++++++++++++----- apps/web/src/authBootstrap.test.ts | 74 +++++++- apps/web/src/authBootstrap.ts | 13 +- .../components/WebSocketConnectionSurface.tsx | 4 + apps/web/src/routes/__root.tsx | 9 +- apps/web/test/authHttpHandlers.ts | 2 - packages/contracts/src/auth.ts | 1 - 14 files changed, 417 insertions(+), 105 deletions(-) create mode 100644 apps/server/src/auth/Layers/ServerAuthPolicy.test.ts diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index a8fc430bb4..479ca957b5 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -1,7 +1,6 @@ import { type AuthBootstrapResult, type AuthSessionState } from "@t3tools/contracts"; -import { DateTime, Effect, Layer, Option } from "effect"; +import { DateTime, Effect, Layer } from "effect"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; -import { HttpServerRequest as HttpServerRequestModule } from "effect/unstable/http"; import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; @@ -16,6 +15,11 @@ import { } from "../Services/ServerAuth.ts"; import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +type BootstrapExchangeResult = { + readonly response: AuthBootstrapResult; + readonly sessionToken: string; +}; + const AUTHORIZATION_PREFIX = "Bearer "; function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { @@ -27,15 +31,6 @@ function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string return token.length > 0 ? token : null; } -function parseQueryToken(request: HttpServerRequest.HttpServerRequest): string | null { - const url = HttpServerRequestModule.toURL(request); - if (Option.isNone(url)) { - return null; - } - const token = url.value.searchParams.get("token"); - return token && token.length > 0 ? token : null; -} - export const makeServerAuth = Effect.gen(function* () { const policy = yield* ServerAuthPolicy; const bootstrapCredentials = yield* BootstrapCredentialService; @@ -61,8 +56,7 @@ export const makeServerAuth = Effect.gen(function* () { const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { const cookieToken = request.cookies[sessions.cookieName]; const bearerToken = parseBearerToken(request); - const queryToken = parseQueryToken(request); - const credential = cookieToken ?? bearerToken ?? queryToken; + const credential = cookieToken ?? bearerToken; if (!credential) { return Effect.fail( new AuthError({ @@ -112,11 +106,13 @@ export const makeServerAuth = Effect.gen(function* () { Effect.map( (session) => ({ - authenticated: true, - sessionMethod: session.method, + response: { + authenticated: true, + sessionMethod: session.method, + expiresAt: DateTime.toUtc(session.expiresAt), + } satisfies AuthBootstrapResult, sessionToken: session.token, - expiresAt: DateTime.toUtc(session.expiresAt), - }) satisfies AuthBootstrapResult, + }) satisfies BootstrapExchangeResult, ), ); diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts new file mode 100644 index 0000000000..ea4921870f --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts @@ -0,0 +1,94 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; +import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; + +const makeServerAuthPolicyLayer = (overrides?: Partial) => + ServerAuthPolicyLive.pipe( + Layer.provide( + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })), + ), + ), + ); + +it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { + it.effect("uses desktop-managed-local policy for desktop mode", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("desktop-managed-local"); + expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "desktop", + }), + ), + ), + ); + + it.effect("uses loopback-browser policy for loopback web hosts", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("loopback-browser"); + expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "web", + host: "127.0.0.1", + }), + ), + ), + ); + + it.effect("uses remote-reachable policy for wildcard web hosts", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "web", + host: "0.0.0.0", + }), + ), + ), + ); + + it.effect("uses remote-reachable policy for non-loopback web hosts", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "web", + host: "192.168.1.50", + }), + ), + ), + ); +}); diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts index bb718ae4d1..4f68c62618 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -6,23 +6,42 @@ import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/Server const SESSION_COOKIE_NAME = "t3_session"; +const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +const isLoopbackHost = (host: string | undefined): boolean => { + if (!host || host.length === 0) { + return true; + } + + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "[::1]" || + host.startsWith("127.") + ); +}; + export const makeServerAuthPolicy = Effect.gen(function* () { const config = yield* ServerConfig; - const descriptor: ServerAuthDescriptor = + const policy = config.mode === "desktop" - ? { - policy: "desktop-managed-local", - bootstrapMethods: ["desktop-bootstrap"], - sessionMethods: ["browser-session-cookie", "bearer-session-token"], - sessionCookieName: SESSION_COOKIE_NAME, - } - : { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-session-token"], - sessionCookieName: SESSION_COOKIE_NAME, - }; + ? "desktop-managed-local" + : isWildcardHost(config.host) || !isLoopbackHost(config.host) + ? "remote-reachable" + : "loopback-browser"; + + const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] = + policy === "desktop-managed-local" ? ["desktop-bootstrap"] : ["one-time-token"]; + + const descriptor: ServerAuthDescriptor = { + policy, + bootstrapMethods, + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: SESSION_COOKIE_NAME, + }; return { getDescriptor: () => Effect.succeed(descriptor), diff --git a/apps/server/src/auth/Layers/ServerSecretStore.test.ts b/apps/server/src/auth/Layers/ServerSecretStore.test.ts index b331e476a2..251830e684 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.test.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.test.ts @@ -11,7 +11,7 @@ const makeServerConfigLayer = () => ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); const makeServerSecretStoreLayer = () => - ServerSecretStoreLive.pipe(Layer.provide(makeServerConfigLayer())); + Layer.provide(ServerSecretStoreLive, makeServerConfigLayer()); const PermissionDeniedFileSystemLayer = Layer.effect( FileSystem.FileSystem, @@ -37,7 +37,7 @@ const PermissionDeniedFileSystemLayer = Layer.effect( const makePermissionDeniedSecretStoreLayer = () => ServerSecretStoreLive.pipe( Layer.provide(makeServerConfigLayer()), - Layer.provide(PermissionDeniedFileSystemLayer), + Layer.provideMerge(PermissionDeniedFileSystemLayer), ); const RenameFailureFileSystemLayer = Layer.effect( @@ -64,7 +64,7 @@ const RenameFailureFileSystemLayer = Layer.effect( const makeRenameFailureSecretStoreLayer = () => ServerSecretStoreLive.pipe( Layer.provide(makeServerConfigLayer()), - Layer.provide(RenameFailureFileSystemLayer), + Layer.provideMerge(RenameFailureFileSystemLayer), ); const RemoveFailureFileSystemLayer = Layer.effect( @@ -91,7 +91,7 @@ const RemoveFailureFileSystemLayer = Layer.effect( const makeRemoveFailureSecretStoreLayer = () => ServerSecretStoreLive.pipe( Layer.provide(makeServerConfigLayer()), - Layer.provide(RemoveFailureFileSystemLayer), + Layer.provideMerge(RemoveFailureFileSystemLayer), ); it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { @@ -116,6 +116,45 @@ it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { }).pipe(Effect.provide(makeServerSecretStoreLayer())), ); + it.effect("uses restrictive permissions for the secret directory and files", () => + Effect.gen(function* () { + const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = []; + const recordingFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + makeDirectory: () => Effect.void, + writeFile: () => Effect.void, + rename: () => Effect.void, + chmod: (path, mode) => + Effect.sync(() => { + chmodCalls.push({ path: String(path), mode }); + }), + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeServices.layer)); + + const secretStore = yield* Effect.service(ServerSecretStore).pipe( + Effect.provide( + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(recordingFileSystemLayer), + ), + ), + ); + + yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); + + expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( + true, + ); + expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("propagates read failures other than missing-file errors", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore; diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts index 033d84c5fe..28192fe123 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -16,6 +16,15 @@ export const makeServerSecretStore = Effect.gen(function* () { const serverConfig = yield* ServerConfig; yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); + yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( + Effect.mapError( + (cause) => + new SecretStoreError({ + message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, + cause, + }), + ), + ); const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); @@ -42,7 +51,9 @@ export const makeServerSecretStore = Effect.gen(function* () { const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; return Effect.gen(function* () { yield* fileSystem.writeFile(tempPath, value); + yield* fileSystem.chmod(tempPath, 0o600); yield* fileSystem.rename(tempPath, secretPath); + yield* fileSystem.chmod(secretPath, 0o600); }).pipe( Effect.catch((cause) => fileSystem.remove(tempPath).pipe( diff --git a/apps/server/src/auth/Services/ServerAuth.ts b/apps/server/src/auth/Services/ServerAuth.ts index 005d7b9f9f..4962bff9bb 100644 --- a/apps/server/src/auth/Services/ServerAuth.ts +++ b/apps/server/src/auth/Services/ServerAuth.ts @@ -24,9 +24,13 @@ export interface ServerAuthShape { readonly getSessionState: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; - readonly exchangeBootstrapCredential: ( - credential: string, - ) => Effect.Effect; + readonly exchangeBootstrapCredential: (credential: string) => Effect.Effect< + { + readonly response: AuthBootstrapResult; + readonly sessionToken: string; + }, + AuthError + >; readonly authenticateHttpRequest: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index f8efef7f0b..0666a192c2 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -40,9 +40,9 @@ export const authBootstrapRouteLayer = HttpRouter.add( ); const result = yield* serverAuth.exchangeBootstrapCredential(payload.credential); - return yield* HttpServerResponse.jsonUnsafe(result, { status: 200 }).pipe( + return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe( HttpServerResponse.setCookie(descriptor.sessionCookieName, result.sessionToken, { - expires: DateTime.toDate(result.expiresAt), + expires: DateTime.toDate(result.response.expiresAt), httpOnly: true, path: "/", sameSite: "lax", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 82cacfe42f..69e525a3d0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -43,6 +43,7 @@ import { } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vitest"; import type { ServerConfigShape } from "./config.ts"; @@ -118,8 +119,6 @@ const testEnvironmentDescriptor = { repositoryIdentity: true, }, }; -let cachedDefaultSessionToken: string | null = null; - const makeDefaultOrchestrationReadModel = () => { const now = new Date().toISOString(); return { @@ -303,7 +302,6 @@ const buildAppUnderTest = (options?: { }; }) => Effect.gen(function* () { - cachedDefaultSessionToken = null; const fileSystem = yield* FileSystem.FileSystem; const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" }); const baseDir = options?.config?.baseDir ?? tempBaseDir; @@ -474,11 +472,37 @@ const buildAppUnderTest = (options?: { return config; }); -const wsRpcProtocolLayer = (wsUrl: string) => - RpcClient.layerProtocolSocket().pipe( - Layer.provide(NodeSocket.layerWebSocket(wsUrl)), +const parseSessionCookieFromWsUrl = ( + wsUrl: string, +): { readonly cookie: string | null; readonly url: string } => { + const next = new URL(wsUrl); + const cookie = next.hash.startsWith("#cookie=") + ? decodeURIComponent(next.hash.slice("#cookie=".length)) + : null; + next.hash = ""; + return { + cookie, + url: next.toString(), + }; +}; + +const wsRpcProtocolLayer = (wsUrl: string) => { + const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl); + const webSocketConstructorLayer = Layer.succeed( + Socket.WebSocketConstructor, + (socketUrl, protocols) => + new NodeSocket.NodeWS.WebSocket( + socketUrl, + protocols, + cookie ? { headers: { cookie } } : undefined, + ) as unknown as globalThis.WebSocket, + ); + + return RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(url).pipe(Layer.provide(webSocketConstructorLayer))), Layer.provide(RpcSerialization.layerJson), ); +}; const makeWsRpcClient = RpcClient.make(WsRpcGroup); type WsRpcClient = @@ -489,10 +513,10 @@ const withWsRpcClient = ( f: (client: WsRpcClient) => Effect.Effect, ) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); -const appendSessionTokenToUrl = (url: string, sessionToken: string) => { +const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => { const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url); const next = new URL(url, "http://localhost"); - next.searchParams.set("token", sessionToken); + next.hash = `cookie=${encodeURIComponent(sessionCookieHeader)}`; return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; }; @@ -520,7 +544,6 @@ const bootstrapBrowserSession = (credential = defaultDesktopBootstrapToken) => const body = (yield* Effect.promise(() => response.json())) as { readonly authenticated: boolean; readonly sessionMethod: string; - readonly sessionToken: string; readonly expiresAt: string; }; return { @@ -530,29 +553,34 @@ const bootstrapBrowserSession = (credential = defaultDesktopBootstrapToken) => }; }); -const getAuthenticatedSessionToken = (credential = defaultDesktopBootstrapToken) => +const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => Effect.gen(function* () { - if (credential === defaultDesktopBootstrapToken && cachedDefaultSessionToken) { - return cachedDefaultSessionToken; - } - - const { response, body } = yield* bootstrapBrowserSession(credential); + const { response, cookie } = yield* bootstrapBrowserSession(credential); if (!response.ok) { return yield* Effect.fail( new Error(`Expected bootstrap session response to succeed, got ${response.status}`), ); } - if (credential === defaultDesktopBootstrapToken) { - cachedDefaultSessionToken = body.sessionToken; + if (!cookie) { + return yield* Effect.fail(new Error("Expected bootstrap session response to set a cookie.")); } - return body.sessionToken; + return cookie.split(";")[0] ?? cookie; }); +const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { + const [nameValue] = cookieHeader.split(";", 1); + const token = nameValue?.split("=", 2)[1]; + if (!token) { + throw new Error("Expected session cookie header to contain a token value."); + } + return token; +}; + const getWsServerUrl = ( pathname = "", - options?: { authenticated?: boolean; sessionToken?: string; credential?: string }, + options?: { authenticated?: boolean; credential?: string }, ) => Effect.gen(function* () { const server = yield* HttpServer.HttpServer; @@ -561,9 +589,9 @@ const getWsServerUrl = ( if (options?.authenticated === false) { return baseUrl; } - return appendSessionTokenToUrl( + return appendSessionCookieToWsUrl( baseUrl, - options?.sessionToken ?? (yield* getAuthenticatedSessionToken(options?.credential)), + yield* getAuthenticatedSessionCookieHeader(options?.credential), ); }); @@ -615,10 +643,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const response = yield* HttpClient.get( - appendSessionTokenToUrl( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - yield* getAuthenticatedSessionToken(), - ), + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 200); @@ -638,10 +668,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const response = yield* HttpClient.get( - appendSessionTokenToUrl( - `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, - yield* getAuthenticatedSessionToken(), - ), + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 200); @@ -690,6 +722,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(bootstrapResponse.status, 200); assert.equal(bootstrapBody.authenticated, true); assert.equal(bootstrapBody.sessionMethod, "browser-session-cookie"); + assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken); assert.isDefined(setCookie); const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); @@ -727,17 +760,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("accepts websocket rpc handshake with a bootstrapped session token", () => + it.effect( + "does not accept session tokens via query parameters on authenticated HTTP routes", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const projectDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-router-project-favicon-query-token-", + }); + + yield* buildAppUnderTest(); + + const { cookie } = yield* bootstrapBrowserSession(); + assert.isDefined(cookie); + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + + const response = yield* HttpClient.get( + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`, + ); + + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => Effect.gen(function* () { yield* buildAppUnderTest(); - const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBrowserSession(); + const { response: bootstrapResponse, cookie } = yield* bootstrapBrowserSession(); assert.equal(bootstrapResponse.status, 200); + assert.isDefined(cookie); - const wsUrl = appendSessionTokenToUrl( + const wsUrl = appendSessionCookieToWsUrl( yield* getWsServerUrl("/ws", { authenticated: false }), - bootstrapBody.sessionToken, + cookie?.split(";")[0] ?? "", ); const response = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), @@ -748,6 +805,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "rejects websocket rpc handshake when a session token is only provided via query string", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { cookie } = yield* bootstrapBrowserSession(); + assert.isDefined(cookie); + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?token=${encodeURIComponent(sessionToken)}`; + + const error = yield* Effect.flip( + Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), + ); + + assert.equal(error._tag, "RpcClientError"); + assertInclude(String(error), "401"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("serves attachment files from state dir", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -764,12 +841,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); - const response = yield* HttpClient.get( - appendSessionTokenToUrl( - `/attachments/${attachmentId}`, - yield* getAuthenticatedSessionToken(), - ), - ); + const response = yield* HttpClient.get(`/attachments/${attachmentId}`, { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }); assert.equal(response.status, 200); assert.equal(yield* response.text, "attachment-ok"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -791,10 +867,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok"); const response = yield* HttpClient.get( - appendSessionTokenToUrl( - "/attachments/thread%20folder/message%20folder/file%20name.png", - yield* getAuthenticatedSessionToken(), - ), + "/attachments/thread%20folder/message%20folder/file%20name.png", + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 200); assert.equal(yield* response.text, "attachment-encoded-ok"); @@ -931,7 +1009,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.post("/api/observability/v1/traces", { headers: { - authorization: `Bearer ${yield* getAuthenticatedSessionToken()}`, + cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", origin: "http://localhost:5733", }, @@ -1040,7 +1118,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.post("/api/observability/v1/traces", { headers: { - authorization: `Bearer ${yield* getAuthenticatedSessionToken()}`, + cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", }, body: HttpBody.text(JSON.stringify(payload), "application/json"), @@ -1087,10 +1165,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest(); const response = yield* HttpClient.get( - appendSessionTokenToUrl( - "/attachments/missing-11111111-1111-4111-8111-111111111111", - yield* getAuthenticatedSessionToken(), - ), + "/attachments/missing-11111111-1111-4111-8111-111111111111", + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 404); }).pipe(Effect.provide(NodeHttpServer.layerTest)), diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 509ce34edb..315dcfacd2 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -65,7 +65,19 @@ describe("resolveInitialServerAuthGateState", () => { jsonResponse({ authenticated: true, sessionMethod: "browser-session-cookie", - sessionToken: "session-token", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", expiresAt: "2026-04-05T00:00:00.000Z", }), ); @@ -207,7 +219,19 @@ describe("resolveInitialServerAuthGateState", () => { jsonResponse({ authenticated: true, sessionMethod: "browser-session-cookie", - sessionToken: "session-token", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", expiresAt: "2026-04-05T00:00:00.000Z", }), ); @@ -230,6 +254,52 @@ describe("resolveInitialServerAuthGateState", () => { await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ status: "authenticated", }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("revalidates the server session state after a previous authenticated result", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts index d4d4059d87..d81dd96077 100644 --- a/apps/web/src/authBootstrap.ts +++ b/apps/web/src/authBootstrap.ts @@ -108,7 +108,7 @@ export async function submitServerAuthCredential(credential: string): Promise { - bootstrapPromise = null; - throw error; + const nextBootstrapPromise = bootstrapServerAuth(); + bootstrapPromise = nextBootstrapPromise; + return nextBootstrapPromise.finally(() => { + if (bootstrapPromise === nextBootstrapPromise) { + bootstrapPromise = null; + } }); - - return bootstrapPromise; } export function __resetServerAuthBootstrapForTests() { diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index c07f59bb9d..4a6a819b32 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -368,3 +368,7 @@ export function SlowRpcAckToastCoordinator() { return null; } + +export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { + return children; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index a06ea21ff4..585e982982 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -23,10 +23,10 @@ import { Throttler } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; -import { SplashScreen } from "../components/SplashScreen"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, + WebSocketConnectionSurface, } from "../components/WebSocketConnectionSurface"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; @@ -86,7 +86,6 @@ export const Route = createRootRouteWithContext<{ function RootRouteView() { const pathname = useLocation({ select: (location) => location.pathname }); - const bootstrapComplete = useStore((store) => store.bootstrapComplete); const { authGateState } = Route.useRouteContext(); if (pathname === "/pair") { @@ -104,13 +103,11 @@ function RootRouteView() { - {bootstrapComplete ? ( + - ) : ( - - )} + ); diff --git a/apps/web/test/authHttpHandlers.ts b/apps/web/test/authHttpHandlers.ts index d45dc57409..54412fdd10 100644 --- a/apps/web/test/authHttpHandlers.ts +++ b/apps/web/test/authHttpHandlers.ts @@ -2,7 +2,6 @@ import type { ServerAuthDescriptor } from "@t3tools/contracts"; import { HttpResponse, http } from "msw"; const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z"; -const TEST_SESSION_TOKEN = "browser-test-session-token"; export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) { return [ @@ -18,7 +17,6 @@ export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => Serv HttpResponse.json({ authenticated: true, sessionMethod: "browser-session-cookie", - sessionToken: TEST_SESSION_TOKEN, expiresAt: TEST_SESSION_EXPIRES_AT, }), ), diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 83d7345618..bea9fa25fc 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -105,7 +105,6 @@ export type AuthBootstrapInput = typeof AuthBootstrapInput.Type; export const AuthBootstrapResult = Schema.Struct({ authenticated: Schema.Literal(true), sessionMethod: ServerAuthSessionMethod, - sessionToken: TrimmedNonEmptyString, expiresAt: Schema.DateTimeUtc, }); export type AuthBootstrapResult = typeof AuthBootstrapResult.Type; From bc1bacb7673aeeedb662f8b807fb4515a8dde629 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 10:18:35 -0700 Subject: [PATCH 13/16] test: split server test layer pipeline Co-authored-by: codex --- apps/server/src/server.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 69e525a3d0..0d91a1a12b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -338,7 +338,7 @@ const buildAppUnderTest = (options?: { }); const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); - const appLayer = HttpRouter.serve(makeRoutesLayer, { + const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, disableLogger: true, }).pipe( @@ -427,6 +427,9 @@ const buildAppUnderTest = (options?: { ...options?.layers?.checkpointDiffQuery, }), ), + ); + + const appLayer = servedRoutesLayer.pipe( Layer.provide( Layer.mock(BrowserTraceCollector)({ record: () => Effect.void, From b5b1f8eafa278369f4b2afe03bbdd46e46acdc3b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 21:30:51 -0700 Subject: [PATCH 14/16] fix: restore verification after stacked rebase Co-authored-by: codex --- .../src/environment/Layers/ServerEnvironment.test.ts | 2 +- apps/web/src/lib/utils.test.ts | 2 ++ apps/web/src/lib/utils.ts | 8 ++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts index a9668760f2..7505e2ccfd 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.test.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -32,7 +32,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { logWebSocketEvents: false, port: 0, host: undefined, - authToken: undefined, + desktopBootstrapToken: undefined, staticDir: undefined, devUrl: undefined, noBrowser: false, diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 57d2cce4f9..a4d326634b 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -50,6 +50,7 @@ afterEach(() => { describe("resolveServerHttpUrl", () => { it("uses the Vite dev origin for local HTTP requests automatically", () => { + resolvePrimaryEnvironmentBootstrapUrlMock.mockReturnValueOnce(""); vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3775/ws"); assert.equal( @@ -90,6 +91,7 @@ describe("resolveServerUrl", () => { }); it("keeps the backend origin for websocket requests", () => { + resolvePrimaryEnvironmentBootstrapUrlMock.mockReturnValueOnce(""); vi.stubEnv("VITE_WS_URL", "ws://127.0.0.1:3775/ws"); assert.equal( diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 183735d340..b2344fa64a 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -41,6 +41,14 @@ export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID()); export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty); +const firstNonEmptyString = (...values: unknown[]): string => { + for (const value of values) { + if (isNonEmptyString(value)) { + return value; + } + } + throw new Error("No non-empty string provided"); +}; export const resolveServerUrl = (options?: { url?: string | undefined; From 1c365651f2fab4fed9926794fe57fb2d667363d5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 7 Apr 2026 10:37:41 -0700 Subject: [PATCH 15/16] Add remote auth pairing for desktop backend - Add desktop server exposure settings and LAN readiness handling - Extend server auth flows with pairing credentials and remote-reachable policy - Update IPC, preload, and tests for the new pairing flow --- apps/desktop/src/backendReadiness.test.ts | 56 ++++ apps/desktop/src/backendReadiness.ts | 88 ++++++ apps/desktop/src/desktopSettings.test.ts | 50 ++++ apps/desktop/src/desktopSettings.ts | 39 +++ apps/desktop/src/main.ts | 190 ++++++++++-- apps/desktop/src/preload.ts | 4 + apps/desktop/src/serverExposure.test.ts | 139 +++++++++ apps/desktop/src/serverExposure.ts | 91 ++++++ .../Layers/BootstrapCredentialService.test.ts | 14 +- .../auth/Layers/BootstrapCredentialService.ts | 12 +- .../server/src/auth/Layers/ServerAuth.test.ts | 72 +++++ apps/server/src/auth/Layers/ServerAuth.ts | 33 ++- .../src/auth/Layers/ServerAuthPolicy.test.ts | 17 ++ .../src/auth/Layers/ServerAuthPolicy.ts | 13 +- .../Layers/SessionCredentialService.test.ts | 3 + .../auth/Layers/SessionCredentialService.ts | 4 + .../Services/BootstrapCredentialService.ts | 13 +- apps/server/src/auth/Services/ServerAuth.ts | 7 + .../auth/Services/SessionCredentialService.ts | 5 + apps/server/src/auth/http.ts | 20 +- apps/server/src/cli-config.test.ts | 5 + apps/server/src/http.test.ts | 27 ++ apps/server/src/http.ts | 30 +- apps/server/src/server.test.ts | 76 ++++- apps/server/src/server.ts | 7 +- apps/web/src/authBootstrap.test.ts | 60 ++++ apps/web/src/authBootstrap.ts | 117 ++++++-- .../settings/SettingsPanels.browser.tsx | 148 +++++++++- .../components/settings/SettingsPanels.tsx | 270 ++++++++++++++++++ apps/web/src/localApi.test.ts | 10 + packages/contracts/src/auth.ts | 6 + packages/contracts/src/ipc.ts | 10 + 32 files changed, 1570 insertions(+), 66 deletions(-) create mode 100644 apps/desktop/src/backendReadiness.test.ts create mode 100644 apps/desktop/src/backendReadiness.ts create mode 100644 apps/desktop/src/desktopSettings.test.ts create mode 100644 apps/desktop/src/desktopSettings.ts create mode 100644 apps/desktop/src/serverExposure.test.ts create mode 100644 apps/desktop/src/serverExposure.ts create mode 100644 apps/server/src/auth/Layers/ServerAuth.test.ts create mode 100644 apps/server/src/http.test.ts diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts new file mode 100644 index 0000000000..6ce69974a7 --- /dev/null +++ b/apps/desktop/src/backendReadiness.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + BackendReadinessAbortedError, + isBackendReadinessAborted, + waitForHttpReady, +} from "./backendReadiness"; + +describe("waitForHttpReady", () => { + it("returns once the backend reports a successful session endpoint", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + await waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 1_000, + intervalMs: 0, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("aborts an in-flight readiness wait", async () => { + const controller = new AbortController(); + const fetchImpl = vi.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + controller.signal.addEventListener( + "abort", + () => { + reject(new BackendReadinessAbortedError()); + }, + { once: true }, + ); + }) as ReturnType, + ); + + const waitPromise = waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 1_000, + intervalMs: 0, + signal: controller.signal, + }); + + controller.abort(); + + await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError); + }); + + it("recognizes aborted readiness errors", () => { + expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true); + expect(isBackendReadinessAborted(new Error("nope"))).toBe(false); + }); +}); diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts new file mode 100644 index 0000000000..8ffaedfdd5 --- /dev/null +++ b/apps/desktop/src/backendReadiness.ts @@ -0,0 +1,88 @@ +export interface WaitForHttpReadyOptions { + readonly timeoutMs?: number; + readonly intervalMs?: number; + readonly fetchImpl?: typeof fetch; + readonly signal?: AbortSignal; +} + +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_INTERVAL_MS = 100; + +export class BackendReadinessAbortedError extends Error { + constructor() { + super("Backend readiness wait was aborted."); + this.name = "BackendReadinessAbortedError"; + } +} + +function delay(ms: number, signal: AbortSignal | undefined): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + + const onAbort = () => { + cleanup(); + reject(new BackendReadinessAbortedError()); + }; + + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }; + + if (signal?.aborted) { + cleanup(); + reject(new BackendReadinessAbortedError()); + return; + } + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError { + return error instanceof BackendReadinessAbortedError; +} + +export async function waitForHttpReady( + baseUrl: string, + options?: WaitForHttpReadyOptions, +): Promise { + const fetchImpl = options?.fetchImpl ?? fetch; + const signal = options?.signal; + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; + const deadline = Date.now() + timeoutMs; + + for (;;) { + if (signal?.aborted) { + throw new BackendReadinessAbortedError(); + } + + try { + const response = await fetchImpl(`${baseUrl}/api/auth/session`, { + redirect: "manual", + ...(signal ? { signal } : {}), + }); + if (response.ok) { + return; + } + } catch (error) { + if (isBackendReadinessAborted(error)) { + throw error; + } + if (signal?.aborted) { + throw new BackendReadinessAbortedError(); + } + // Retry until the backend becomes reachable or the deadline expires. + } + + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); + } + + await delay(intervalMs, signal); + } +} diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts new file mode 100644 index 0000000000..a4521bb433 --- /dev/null +++ b/apps/desktop/src/desktopSettings.test.ts @@ -0,0 +1,50 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + DEFAULT_DESKTOP_SETTINGS, + readDesktopSettings, + writeDesktopSettings, +} from "./desktopSettings"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeSettingsPath() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-")); + tempDirectories.push(directory); + return path.join(directory, "desktop-settings.json"); +} + +describe("desktopSettings", () => { + it("returns defaults when no settings file exists", () => { + expect(readDesktopSettings(makeSettingsPath())).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it("persists and reloads the configured server exposure mode", () => { + const settingsPath = makeSettingsPath(); + + writeDesktopSettings(settingsPath, { + serverExposureMode: "network-accessible", + }); + + expect(readDesktopSettings(settingsPath)).toEqual({ + serverExposureMode: "network-accessible", + }); + }); + + it("falls back to defaults when the settings file is malformed", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync(settingsPath, "{not-json", "utf8"); + + expect(readDesktopSettings(settingsPath)).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); +}); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts new file mode 100644 index 0000000000..128fd468bc --- /dev/null +++ b/apps/desktop/src/desktopSettings.ts @@ -0,0 +1,39 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; +import type { DesktopServerExposureMode } from "@t3tools/contracts"; + +export interface DesktopSettings { + readonly serverExposureMode: DesktopServerExposureMode; +} + +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + serverExposureMode: "local-only", +}; + +export function readDesktopSettings(settingsPath: string): DesktopSettings { + try { + if (!FS.existsSync(settingsPath)) { + return DEFAULT_DESKTOP_SETTINGS; + } + + const raw = FS.readFileSync(settingsPath, "utf8"); + const parsed = JSON.parse(raw) as { + readonly serverExposureMode?: unknown; + }; + + return { + serverExposureMode: + parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + }; + } catch { + return DEFAULT_DESKTOP_SETTINGS; + } +} + +export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void { + const directory = Path.dirname(settingsPath); + const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; + FS.mkdirSync(directory, { recursive: true }); + FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + FS.renameSync(tempPath, settingsPath); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14eb2e3d5b..ab2ce183c0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -19,6 +19,8 @@ import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { DesktopTheme, + DesktopServerExposureMode, + DesktopServerExposureState, DesktopUpdateActionResult, DesktopUpdateCheckResult, DesktopUpdateState, @@ -29,7 +31,14 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { + DEFAULT_DESKTOP_SETTINGS, + readDesktopSettings, + writeDesktopSettings, +} from "./desktopSettings"; +import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { resolveDesktopServerExposure } from "./serverExposure"; import { syncShellEnvironment } from "./syncShellEnvironment"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; import { @@ -61,8 +70,11 @@ const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; +const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); +const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); const DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); @@ -93,9 +105,13 @@ type LinuxDesktopNamedApp = Electron.App & { let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; +let backendBindHost = DESKTOP_LOOPBACK_HOST; let backendBootstrapToken = ""; let backendHttpUrl = ""; let backendWsUrl = ""; +let backendEndpointUrl: string | null = null; +let backendAdvertisedHost: string | null = null; +let backendReadinessAbortController: AbortController | null = null; let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; @@ -105,6 +121,8 @@ let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); +let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH); +let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); @@ -172,9 +190,91 @@ function backendChildEnv(): NodeJS.ProcessEnv { delete env.T3CODE_NO_BROWSER; delete env.T3CODE_HOST; delete env.T3CODE_DESKTOP_WS_URL; + delete env.T3CODE_DESKTOP_LAN_ACCESS; + delete env.T3CODE_DESKTOP_LAN_HOST; return env; } +function getDesktopServerExposureState(): DesktopServerExposureState { + return { + mode: desktopServerExposureMode, + endpointUrl: backendEndpointUrl, + advertisedHost: backendAdvertisedHost, + }; +} + +function resolveAdvertisedHostOverride(): string | undefined { + const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); + return override && override.length > 0 ? override : undefined; +} + +async function applyDesktopServerExposureMode( + mode: DesktopServerExposureMode, + options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean }, +): Promise { + const advertisedHostOverride = resolveAdvertisedHostOverride(); + const exposure = resolveDesktopServerExposure({ + mode, + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }); + + if (mode === "network-accessible" && exposure.mode !== "network-accessible") { + if (options?.rejectIfUnavailable) { + throw new Error("No reachable network address is available for this desktop right now."); + } + mode = "local-only"; + } + + desktopServerExposureMode = exposure.mode; + desktopSettings = + exposure.mode === desktopSettings.serverExposureMode + ? desktopSettings + : { + ...desktopSettings, + serverExposureMode: exposure.mode, + }; + backendBindHost = exposure.bindHost; + backendHttpUrl = exposure.localHttpUrl; + backendWsUrl = exposure.localWsUrl; + backendEndpointUrl = exposure.endpointUrl; + backendAdvertisedHost = exposure.advertisedHost; + + if (options?.persist || exposure.mode !== mode) { + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + } + + return getDesktopServerExposureState(); +} + +function relaunchDesktopApp(reason: string): void { + writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); + setImmediate(() => { + isQuitting = true; + clearUpdatePollTimer(); + cancelBackendReadinessWait(); + void stopBackendAndWaitForExit() + .catch((error) => { + writeDesktopLogHeader( + `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, + ); + }) + .finally(() => { + restoreStdIoCapture?.(); + if (isDevelopment) { + app.exit(75); + return; + } + app.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }); + app.exit(0); + }); + }); +} + function writeDesktopLogHeader(message: string): void { if (!desktopLogSink) return; desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); @@ -223,28 +323,26 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { } async function waitForBackendHttpReady(baseUrl: string): Promise { - const deadline = Date.now() + 10_000; - - for (;;) { - try { - const response = await fetch(`${baseUrl}/api/auth/session`, { - redirect: "manual", - }); - if (response.ok) { - return; - } - } catch { - // Retry until the backend becomes reachable or the deadline expires. - } + cancelBackendReadinessWait(); + const controller = new AbortController(); + backendReadinessAbortController = controller; - if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); + try { + await waitForHttpReady(baseUrl, { + signal: controller.signal, + }); + } finally { + if (backendReadinessAbortController === controller) { + backendReadinessAbortController = null; } - - await new Promise((resolve) => setTimeout(resolve, 100)); } } +function cancelBackendReadinessWait(): void { + backendReadinessAbortController?.abort(); + backendReadinessAbortController = null; +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -1099,7 +1197,7 @@ function startBackend(): void { noBrowser: true, port: backendPort, t3Home: BASE_DIR, - host: DESKTOP_LOOPBACK_HOST, + host: backendBindHost, desktopBootstrapToken: backendBootstrapToken, ...(backendObservabilitySettings.otlpTracesUrl ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } @@ -1159,6 +1257,7 @@ function startBackend(): void { } function stopBackend(): void { + cancelBackendReadinessWait(); if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; @@ -1180,6 +1279,7 @@ function stopBackend(): void { } async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { + cancelBackendReadinessWait(); if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; @@ -1246,6 +1346,28 @@ function registerIpcHandlers(): void { } as const; }); + ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); + ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); + + ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); + ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { + if (rawMode !== "local-only" && rawMode !== "network-accessible") { + throw new Error("Invalid desktop server exposure mode."); + } + + const nextMode = rawMode as DesktopServerExposureMode; + if (nextMode === desktopServerExposureMode) { + return getDesktopServerExposureState(); + } + + const nextState = await applyDesktopServerExposureMode(nextMode, { + persist: true, + rejectIfUnavailable: true, + }); + relaunchDesktopApp(`serverExposureMode=${nextMode}`); + return nextState; + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -1540,9 +1662,27 @@ async function bootstrap(): Promise { : `using configured backend port port=${backendPort}`, ); backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); - backendHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${backendPort}`; - backendWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${backendPort}`; + if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { + writeDesktopLogHeader( + `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, + ); + } + const serverExposureState = await applyDesktopServerExposureMode( + desktopSettings.serverExposureMode, + { + persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + }, + ); writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); + if (serverExposureState.endpointUrl) { + writeDesktopLogHeader( + `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`, + ); + } else if (desktopSettings.serverExposureMode === "network-accessible") { + writeDesktopLogHeader( + "bootstrap fell back to local-only because no advertised network host was available", + ); + } registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); @@ -1557,7 +1697,13 @@ async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap backend ready"); }) .catch((error) => { - handleFatalStartupError("bootstrap", error); + if (isBackendReadinessAborted(error)) { + return; + } + writeDesktopLogHeader( + `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, + ); + console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); }); return; } @@ -1573,6 +1719,7 @@ app.on("before-quit", () => { updateInstallInFlight = false; writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); + cancelBackendReadinessWait(); stopBackend(); restoreStdIoCapture?.(); }); @@ -1611,6 +1758,7 @@ if (process.platform !== "win32") { isQuitting = true; writeDesktopLogHeader("SIGINT received"); clearUpdatePollTimer(); + cancelBackendReadinessWait(); stopBackend(); restoreStdIoCapture?.(); app.quit(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index bd678844ef..9a974f4da4 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -14,6 +14,8 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; +const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => { @@ -27,6 +29,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), + setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts new file mode 100644 index 0000000000..fc12ddd0b3 --- /dev/null +++ b/apps/desktop/src/serverExposure.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure"; + +describe("resolveLanAdvertisedHost", () => { + it("prefers an explicit host override", () => { + expect( + resolveLanAdvertisedHost( + { + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + "10.0.0.9", + ), + ).toBe("10.0.0.9"); + }); + + it("returns the first usable non-internal IPv4 address", () => { + expect( + resolveLanAdvertisedHost( + { + lo0: [ + { + address: "127.0.0.1", + family: "IPv4", + internal: true, + netmask: "255.0.0.0", + cidr: "127.0.0.1/8", + mac: "00:00:00:00:00:00", + }, + ], + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + undefined, + ), + ).toBe("192.168.1.44"); + }); + + it("returns null when no usable network address is available", () => { + expect( + resolveLanAdvertisedHost( + { + lo0: [ + { + address: "127.0.0.1", + family: "IPv4", + internal: true, + netmask: "255.0.0.0", + cidr: "127.0.0.1/8", + mac: "00:00:00:00:00:00", + }, + ], + }, + undefined, + ), + ).toBeNull(); + }); +}); + +describe("resolveDesktopServerExposure", () => { + it("keeps the desktop server loopback-only when local-only mode is selected", () => { + expect( + resolveDesktopServerExposure({ + mode: "local-only", + port: 3773, + networkInterfaces: {}, + }), + ).toEqual({ + mode: "local-only", + bindHost: "127.0.0.1", + localHttpUrl: "http://127.0.0.1:3773", + localWsUrl: "ws://127.0.0.1:3773", + endpointUrl: null, + advertisedHost: null, + }); + }); + + it("creates a network endpoint when network-accessible mode resolves a LAN address", () => { + expect( + resolveDesktopServerExposure({ + mode: "network-accessible", + port: 3773, + networkInterfaces: { + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + }), + ).toEqual({ + mode: "network-accessible", + bindHost: "0.0.0.0", + localHttpUrl: "http://127.0.0.1:3773", + localWsUrl: "ws://127.0.0.1:3773", + endpointUrl: "http://192.168.1.44:3773", + advertisedHost: "192.168.1.44", + }); + }); + + it("falls back to loopback when network-accessible mode has no reachable address", () => { + expect( + resolveDesktopServerExposure({ + mode: "network-accessible", + port: 3773, + networkInterfaces: {}, + }), + ).toEqual({ + mode: "local-only", + bindHost: "127.0.0.1", + localHttpUrl: "http://127.0.0.1:3773", + localWsUrl: "ws://127.0.0.1:3773", + endpointUrl: null, + advertisedHost: null, + }); + }); +}); diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts new file mode 100644 index 0000000000..67b9c145ae --- /dev/null +++ b/apps/desktop/src/serverExposure.ts @@ -0,0 +1,91 @@ +import type { NetworkInterfaceInfo } from "node:os"; +import type { DesktopServerExposureMode } from "@t3tools/contracts"; + +const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; + +export interface DesktopServerExposure { + readonly mode: DesktopServerExposureMode; + readonly bindHost: string; + readonly localHttpUrl: string; + readonly localWsUrl: string; + readonly endpointUrl: string | null; + readonly advertisedHost: string | null; +} + +const normalizeOptionalHost = (value: string | undefined): string | undefined => { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +}; + +const isUsableLanIpv4Address = (address: string): boolean => + !address.startsWith("127.") && !address.startsWith("169.254."); + +export function resolveLanAdvertisedHost( + networkInterfaces: NodeJS.Dict, + explicitHost: string | undefined, +): string | null { + const normalizedExplicitHost = normalizeOptionalHost(explicitHost); + if (normalizedExplicitHost) { + return normalizedExplicitHost; + } + + for (const interfaceAddresses of Object.values(networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isUsableLanIpv4Address(address.address)) continue; + return address.address; + } + } + + return null; +} + +export function resolveDesktopServerExposure(input: { + readonly mode: DesktopServerExposureMode; + readonly port: number; + readonly networkInterfaces: NodeJS.Dict; + readonly advertisedHostOverride?: string; +}): DesktopServerExposure { + const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + + if (input.mode === "local-only") { + return { + mode: input.mode, + bindHost: DESKTOP_LOOPBACK_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: null, + advertisedHost: null, + }; + } + + const advertisedHost = resolveLanAdvertisedHost( + input.networkInterfaces, + input.advertisedHostOverride, + ); + + if (!advertisedHost) { + return { + mode: "local-only", + bindHost: DESKTOP_LOOPBACK_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: null, + advertisedHost: null, + }; + } + + return { + mode: input.mode, + bindHost: DESKTOP_LAN_BIND_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: `http://${advertisedHost}:${input.port}`, + advertisedHost, + }; +} diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts index 1ff9ef5564..15d39288fa 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts @@ -31,11 +31,13 @@ it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { it.effect("issues one-time bootstrap tokens that can only be consumed once", () => Effect.gen(function* () { const bootstrapCredentials = yield* BootstrapCredentialService; - const token = yield* bootstrapCredentials.issueOneTimeToken(); - const first = yield* bootstrapCredentials.consume(token); - const second = yield* Effect.flip(bootstrapCredentials.consume(token)); + const issued = yield* bootstrapCredentials.issueOneTimeToken(); + const first = yield* bootstrapCredentials.consume(issued.credential); + const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); expect(first.method).toBe("one-time-token"); + expect(first.role).toBe("client"); + expect(first.subject).toBe("one-time-token"); expect(second._tag).toBe("BootstrapCredentialError"); expect(second.message).toContain("Unknown bootstrap credential"); }).pipe(Effect.provide(makeBootstrapCredentialLayer())), @@ -46,7 +48,9 @@ it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { const bootstrapCredentials = yield* BootstrapCredentialService; const token = yield* bootstrapCredentials.issueOneTimeToken(); const results = yield* Effect.all( - Array.from({ length: 8 }, () => Effect.result(bootstrapCredentials.consume(token))), + Array.from({ length: 8 }, () => + Effect.result(bootstrapCredentials.consume(token.credential)), + ), { concurrency: "unbounded", }, @@ -71,6 +75,8 @@ it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); expect(first.method).toBe("desktop-bootstrap"); + expect(first.role).toBe("owner"); + expect(first.subject).toBe("desktop-bootstrap"); expect(second._tag).toBe("BootstrapCredentialError"); }).pipe( Effect.provide( diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts index 4423a000cc..cab92f0460 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -23,7 +23,6 @@ type ConsumeResult = }; const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); - export const makeBootstrapCredentialService = Effect.gen(function* () { const config = yield* ServerConfig; const grantsRef = yield* Ref.make(new Map()); @@ -39,6 +38,8 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { const now = yield* DateTime.now; yield* seedGrant(config.desktopBootstrapToken, { method: "desktop-bootstrap", + role: "owner", + subject: "desktop-bootstrap", expiresAt: DateTime.add(now, { milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES), }), @@ -53,10 +54,15 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { const now = yield* DateTime.now; yield* seedGrant(credential, { method: "one-time-token", + role: input?.role ?? "client", + subject: input?.subject ?? "one-time-token", expiresAt: DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }), remainingUses: 1, }); - return credential; + return { + credential, + expiresAt: DateTime.toUtc(DateTime.add(now, { milliseconds: Duration.toMillis(ttl) })), + } as const; }); const consume: BootstrapCredentialServiceShape["consume"] = (credential) => @@ -109,6 +115,8 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { _tag: "success", grant: { method: grant.method, + role: grant.role, + subject: grant.subject, expiresAt: grant.expiresAt, } satisfies BootstrapGrant, }, diff --git a/apps/server/src/auth/Layers/ServerAuth.test.ts b/apps/server/src/auth/Layers/ServerAuth.test.ts new file mode 100644 index 0000000000..815acf8217 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuth.test.ts @@ -0,0 +1,72 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts"; +import { ServerAuthLive } from "./ServerAuth.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; + +const makeServerConfigLayer = (overrides?: Partial) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); + +const makeServerAuthLayer = (overrides?: Partial) => + ServerAuthLive.pipe( + Layer.provide(ServerSecretStoreLive), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +const makeCookieRequest = ( + sessionToken: string, +): Parameters[0] => + ({ + cookies: { + t3_session: sessionToken, + }, + headers: {}, + }) as unknown as Parameters[0]; + +it.layer(NodeServices.layer)("ServerAuthLive", (it) => { + it.effect("issues client pairing credentials by default", () => + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + + const pairingCredential = yield* serverAuth.issuePairingCredential(); + const exchanged = yield* serverAuth.exchangeBootstrapCredential(pairingCredential.credential); + const verified = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(exchanged.sessionToken), + ); + + expect(verified.role).toBe("client"); + expect(verified.subject).toBe("one-time-token"); + }).pipe(Effect.provide(makeServerAuthLayer())), + ); + + it.effect("issues startup pairing URLs that bootstrap owner sessions", () => + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + + const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); + const token = new URL(pairingUrl).searchParams.get("token"); + expect(token).toBeTruthy(); + + const exchanged = yield* serverAuth.exchangeBootstrapCredential(token ?? ""); + const verified = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(exchanged.sessionToken), + ); + + expect(verified.role).toBe("owner"); + expect(verified.subject).toBe("owner-bootstrap"); + }).pipe(Effect.provide(makeServerAuthLayer())), + ); +}); diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 479ca957b5..6330f702bd 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -1,4 +1,8 @@ -import { type AuthBootstrapResult, type AuthSessionState } from "@t3tools/contracts"; +import { + type AuthBootstrapResult, + type AuthPairingCredentialResult, + type AuthSessionState, +} from "@t3tools/contracts"; import { DateTime, Effect, Layer } from "effect"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; @@ -42,6 +46,7 @@ export const makeServerAuth = Effect.gen(function* () { Effect.map((session) => ({ subject: session.subject, method: session.method, + role: session.role, ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), })), Effect.mapError( @@ -100,7 +105,8 @@ export const makeServerAuth = Effect.gen(function* () { Effect.flatMap((grant) => sessions.issue({ method: "browser-session-cookie", - subject: grant.method, + subject: grant.subject, + role: grant.role, }), ), Effect.map( @@ -116,12 +122,28 @@ export const makeServerAuth = Effect.gen(function* () { ), ); + const issuePairingCredential: ServerAuthShape["issuePairingCredential"] = (input) => + bootstrapCredentials + .issueOneTimeToken({ + role: input?.role ?? "client", + subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token", + }) + .pipe( + Effect.map( + (issued) => + ({ + credential: issued.credential, + expiresAt: issued.expiresAt, + }) satisfies AuthPairingCredentialResult, + ), + ); + const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) => - bootstrapCredentials.issueOneTimeToken().pipe( - Effect.map((credential) => { + issuePairingCredential({ role: "owner" }).pipe( + Effect.map((issued) => { const url = new URL(baseUrl); url.pathname = "/pair"; - url.searchParams.set("token", credential); + url.searchParams.set("token", issued.credential); return url.toString(); }), ); @@ -130,6 +152,7 @@ export const makeServerAuth = Effect.gen(function* () { getDescriptor: () => Effect.succeed(descriptor), getSessionState, exchangeBootstrapCredential, + issuePairingCredential, authenticateHttpRequest: authenticateRequest, authenticateWebSocketUpgrade: authenticateRequest, issueStartupPairingUrl, diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts index ea4921870f..640cc030f8 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts @@ -42,6 +42,23 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { ), ); + it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "desktop", + host: "0.0.0.0", + }), + ), + ), + ); + it.effect("uses loopback-browser policy for loopback web hosts", () => Effect.gen(function* () { const policy = yield* ServerAuthPolicy; diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts index 4f68c62618..532731efc2 100644 --- a/apps/server/src/auth/Layers/ServerAuthPolicy.ts +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -25,16 +25,23 @@ const isLoopbackHost = (host: string | undefined): boolean => { export const makeServerAuthPolicy = Effect.gen(function* () { const config = yield* ServerConfig; + const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); const policy = config.mode === "desktop" - ? "desktop-managed-local" - : isWildcardHost(config.host) || !isLoopbackHost(config.host) + ? isRemoteReachable + ? "remote-reachable" + : "desktop-managed-local" + : isRemoteReachable ? "remote-reachable" : "loopback-browser"; const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] = - policy === "desktop-managed-local" ? ["desktop-bootstrap"] : ["one-time-token"]; + policy === "desktop-managed-local" + ? ["desktop-bootstrap"] + : config.mode === "desktop" && policy === "remote-reachable" + ? ["desktop-bootstrap", "one-time-token"] + : ["one-time-token"]; const descriptor: ServerAuthDescriptor = { policy, diff --git a/apps/server/src/auth/Layers/SessionCredentialService.test.ts b/apps/server/src/auth/Layers/SessionCredentialService.test.ts index 166a0e4626..0ea1a83513 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.test.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.test.ts @@ -37,11 +37,13 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { const sessions = yield* SessionCredentialService; const issued = yield* sessions.issue({ subject: "desktop-bootstrap", + role: "owner", }); const verified = yield* sessions.verify(issued.token); expect(verified.method).toBe("browser-session-cookie"); expect(verified.subject).toBe("desktop-bootstrap"); + expect(verified.role).toBe("owner"); expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); }).pipe(Effect.provide(makeSessionCredentialLayer())), ); @@ -65,6 +67,7 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { expect(verified.method).toBe("bearer-session-token"); expect(verified.subject).toBe("test-clock"); + expect(verified.role).toBe("client"); }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), ); }); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index ad14f3e9f6..17fe3297aa 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -22,6 +22,7 @@ const SessionClaims = Schema.Struct({ v: Schema.Literal(1), kind: Schema.Literal("session"), sub: Schema.String, + role: Schema.Literals(["owner", "client"]), method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), iat: Schema.Number, exp: Schema.Number, @@ -41,6 +42,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { v: 1, kind: "session", sub: input?.subject ?? "browser", + role: input?.role ?? "client", method: input?.method ?? "browser-session-cookie", iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, @@ -52,6 +54,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { token: `${encodedPayload}.${signature}`, method: claims.method, expiresAt: expiresAt, + role: claims.role, } satisfies IssuedSession; }); @@ -92,6 +95,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { method: claims.method, expiresAt: DateTime.makeUnsafe(claims.exp), subject: claims.sub, + role: claims.role, } satisfies VerifiedSession; }); diff --git a/apps/server/src/auth/Services/BootstrapCredentialService.ts b/apps/server/src/auth/Services/BootstrapCredentialService.ts index dd4083a77a..b3061d82a7 100644 --- a/apps/server/src/auth/Services/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Services/BootstrapCredentialService.ts @@ -2,8 +2,12 @@ import type { ServerAuthBootstrapMethod } from "@t3tools/contracts"; import { Data, DateTime, Duration, ServiceMap } from "effect"; import type { Effect } from "effect"; +export type BootstrapCredentialRole = "owner" | "client"; + export interface BootstrapGrant { readonly method: ServerAuthBootstrapMethod; + readonly role: BootstrapCredentialRole; + readonly subject: string; readonly expiresAt: DateTime.DateTime; } @@ -12,10 +16,17 @@ export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredent readonly cause?: unknown; }> {} +export interface IssuedBootstrapCredential { + readonly credential: string; + readonly expiresAt: DateTime.Utc; +} + export interface BootstrapCredentialServiceShape { readonly issueOneTimeToken: (input?: { readonly ttl?: Duration.Duration; - }) => Effect.Effect; + readonly role?: BootstrapCredentialRole; + readonly subject?: string; + }) => Effect.Effect; readonly consume: (credential: string) => Effect.Effect; } diff --git a/apps/server/src/auth/Services/ServerAuth.ts b/apps/server/src/auth/Services/ServerAuth.ts index 4962bff9bb..f1315dbb9e 100644 --- a/apps/server/src/auth/Services/ServerAuth.ts +++ b/apps/server/src/auth/Services/ServerAuth.ts @@ -1,5 +1,6 @@ import type { AuthBootstrapResult, + AuthPairingCredentialResult, AuthSessionState, ServerAuthDescriptor, ServerAuthSessionMethod, @@ -7,15 +8,18 @@ import type { import { Data, DateTime, ServiceMap } from "effect"; import type { Effect } from "effect"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import type { SessionRole } from "./SessionCredentialService.ts"; export interface AuthenticatedSession { readonly subject: string; readonly method: ServerAuthSessionMethod; + readonly role: SessionRole; readonly expiresAt?: DateTime.DateTime; } export class AuthError extends Data.TaggedError("AuthError")<{ readonly message: string; + readonly status?: 401 | 403; readonly cause?: unknown; }> {} @@ -31,6 +35,9 @@ export interface ServerAuthShape { }, AuthError >; + readonly issuePairingCredential: (input?: { + readonly role?: SessionRole; + }) => Effect.Effect; readonly authenticateHttpRequest: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts index dabf03c816..de467ce0b7 100644 --- a/apps/server/src/auth/Services/SessionCredentialService.ts +++ b/apps/server/src/auth/Services/SessionCredentialService.ts @@ -2,10 +2,13 @@ import type { ServerAuthSessionMethod } from "@t3tools/contracts"; import { Data, DateTime, Duration, ServiceMap } from "effect"; import type { Effect } from "effect"; +export type SessionRole = "owner" | "client"; + export interface IssuedSession { readonly token: string; readonly method: ServerAuthSessionMethod; readonly expiresAt: DateTime.DateTime; + readonly role: SessionRole; } export interface VerifiedSession { @@ -13,6 +16,7 @@ export interface VerifiedSession { readonly method: ServerAuthSessionMethod; readonly expiresAt?: DateTime.DateTime; readonly subject: string; + readonly role: SessionRole; } export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{ @@ -26,6 +30,7 @@ export interface SessionCredentialServiceShape { readonly ttl?: Duration.Duration; readonly subject?: string; readonly method?: ServerAuthSessionMethod; + readonly role?: SessionRole; }) => Effect.Effect; readonly verify: (token: string) => Effect.Effect; } diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 0666a192c2..f0d07f32a2 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -9,7 +9,7 @@ export const toUnauthorizedResponse = (error: AuthError) => { error: error.message, }, - { status: 401 }, + { status: error.status ?? 401 }, ); export const authSessionRouteLayer = HttpRouter.add( @@ -50,3 +50,21 @@ export const authBootstrapRouteLayer = HttpRouter.add( ); }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), ); + +export const authPairingCredentialRouteLayer = HttpRouter.add( + "POST", + "/api/auth/pairing-token", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.authenticateHttpRequest(request); + if (session.role !== "owner") { + return yield* new AuthError({ + message: "Only owner sessions can create pairing credentials.", + status: 403, + }); + } + const result = yield* serverAuth.issuePairingCredential(); + return HttpServerResponse.jsonUnsafe(result, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), +); diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index 63b3974658..6dd48c5d25 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -83,6 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -143,6 +144,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -210,6 +212,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -326,6 +329,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -388,6 +392,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: resolved.staticDir, devUrl: undefined, noBrowser: true, + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, }); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts new file mode 100644 index 0000000000..de861cc664 --- /dev/null +++ b/apps/server/src/http.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; + +describe("http dev routing", () => { + it("treats localhost and loopback addresses as local", () => { + expect(isLoopbackHostname("127.0.0.1")).toBe(true); + expect(isLoopbackHostname("localhost")).toBe(true); + expect(isLoopbackHostname("::1")).toBe(true); + expect(isLoopbackHostname("[::1]")).toBe(true); + }); + + it("does not treat LAN addresses as local", () => { + expect(isLoopbackHostname("192.168.86.35")).toBe(false); + expect(isLoopbackHostname("10.0.0.24")).toBe(false); + expect(isLoopbackHostname("example.local")).toBe(false); + }); + + it("preserves path and query when redirecting to the dev server", () => { + const devUrl = new URL("http://127.0.0.1:5173/"); + const requestUrl = new URL("http://127.0.0.1:3774/pair?token=test-token"); + + expect(resolveDevRedirectUrl(devUrl, requestUrl)).toBe( + "http://127.0.0.1:5173/pair?token=test-token", + ); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 3fd7850ddf..c270d985dd 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -17,7 +17,7 @@ import { resolveAttachmentRelativePath, } from "./attachmentPaths"; import { resolveAttachmentPathById } from "./attachmentStore"; -import { ServerConfig } from "./config"; +import { resolveStaticDir, ServerConfig } from "./config"; import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; @@ -27,6 +27,23 @@ import { toUnauthorizedResponse } from "./auth/http.ts"; const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; const FALLBACK_PROJECT_FAVICON_SVG = ``; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; +const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); + +export function isLoopbackHostname(hostname: string): boolean { + const normalizedHostname = hostname + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, "$1"); + return LOOPBACK_HOSTNAMES.has(normalizedHostname); +} + +export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { + const redirectUrl = new URL(devUrl.toString()); + redirectUrl.pathname = requestUrl.pathname; + redirectUrl.search = requestUrl.search; + redirectUrl.hash = requestUrl.hash; + return redirectUrl.toString(); +} const requireAuthenticatedRequest = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; @@ -204,11 +221,14 @@ export const staticAndDevRouteLayer = HttpRouter.add( } const config = yield* ServerConfig; - if (config.devUrl) { - return HttpServerResponse.redirect(config.devUrl.href, { status: 302 }); + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { + return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { + status: 302, + }); } - if (!config.staticDir) { + const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined); + if (!staticDir) { return HttpServerResponse.text("No static directory configured and no dev URL set.", { status: 503, }); @@ -216,7 +236,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const staticRoot = path.resolve(config.staticDir); + const staticRoot = path.resolve(staticDir); const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0d91a1a12b..3468377a7c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -621,11 +621,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { config: { devUrl: new URL("http://127.0.0.1:5173") }, }); - const url = yield* getHttpServerUrl("/foo/bar"); + const url = yield* getHttpServerUrl("/foo/bar?token=test-token"); const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" })); assert.equal(response.status, 302); - assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/"); + assert.equal( + response.headers.get("location"), + "http://127.0.0.1:5173/foo/bar?token=test-token", + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -747,6 +750,75 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("issues authenticated one-time pairing credentials for additional clients", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const response = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }); + const body = (yield* response.json) as { + readonly credential: string; + readonly expiresAt: string; + }; + + assert.equal(response.status, 200); + assert.equal(typeof body.credential, "string"); + assert.isTrue(body.credential.length > 0); + assert.equal(typeof body.expiresAt, "string"); + + const bootstrapResult = yield* bootstrapBrowserSession(body.credential); + assert.equal(bootstrapResult.response.status, 200); + + const reusedResult = yield* bootstrapBrowserSession(body.credential); + assert.equal(reusedResult.response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects unauthenticated pairing credential requests", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const response = yield* HttpClient.post("/api/auth/pairing-token"); + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects pairing credential requests from non-owner paired sessions", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }); + const ownerBody = (yield* ownerResponse.json) as { + readonly credential: string; + }; + assert.equal(ownerResponse.status, 200); + + const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader(ownerBody.credential); + const pairedResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: pairedSessionCookie, + }, + }); + const pairedBody = (yield* pairedResponse.json) as { + readonly error: string; + }; + + assert.equal(pairedResponse.status, 403); + assert.equal(pairedBody.error, "Only owner sessions can create pairing credentials."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects reusing the same bootstrap credential after it has been exchanged", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4f5a8a0f7b..4fb923b48f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -51,7 +51,11 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; -import { authBootstrapRouteLayer, authSessionRouteLayer } from "./auth/http"; +import { + authBootstrapRouteLayer, + authPairingCredentialRouteLayer, + authSessionRouteLayer, +} from "./auth/http"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; import { ServerAuthLive } from "./auth/Layers/ServerAuth"; @@ -222,6 +226,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( export const makeRoutesLayer = Layer.mergeAll( authBootstrapRouteLayer, + authPairingCredentialRouteLayer, authSessionRouteLayer, attachmentsRouteLayer, otlpTracesProxyRouteLayer, diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 315dcfacd2..8e87d20190 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -38,12 +38,14 @@ function installTestBrowser(url: string) { describe("resolveInitialServerAuthGateState", () => { beforeEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); installTestBrowser("http://localhost/"); }); afterEach(async () => { const { __resetServerAuthBootstrapForTests } = await import("./authBootstrap"); __resetServerAuthBootstrapForTests(); + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -193,6 +195,43 @@ describe("resolveInitialServerAuthGateState", () => { }); }); + it("retries transient auth session bootstrap failures after restart", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) + .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) + .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(gateStatePromise).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + it("takes a pairing token from the location and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/?token=pairing-token"); const { takePairingTokenFromUrl } = await import("./authBootstrap"); @@ -302,4 +341,25 @@ describe("resolveInitialServerAuthGateState", () => { }); expect(fetchMock).toHaveBeenCalledTimes(2); }); + + it("creates a pairing credential from the authenticated auth endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + credential: "pairing-token", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { createServerPairingCredential } = await import("./authBootstrap"); + + await expect(createServerPairingCredential()).resolves.toEqual({ + credential: "pairing-token", + expiresAt: "2026-04-05T00:00:00.000Z", + }); + expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/pairing-token", { + credentials: "include", + method: "POST", + }); + }); }); diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts index d81dd96077..65402bbb73 100644 --- a/apps/web/src/authBootstrap.ts +++ b/apps/web/src/authBootstrap.ts @@ -1,4 +1,9 @@ -import type { AuthBootstrapInput, AuthBootstrapResult, AuthSessionState } from "@t3tools/contracts"; +import type { + AuthBootstrapInput, + AuthBootstrapResult, + AuthPairingCredentialResult, + AuthSessionState, +} from "@t3tools/contracts"; import { resolveServerHttpUrl } from "./lib/utils"; export type ServerAuthGateState = @@ -10,6 +15,19 @@ export type ServerAuthGateState = }; let bootstrapPromise: Promise | null = null; +const TRANSIENT_AUTH_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); +const AUTH_BOOTSTRAP_RETRY_TIMEOUT_MS = 15_000; +const AUTH_BOOTSTRAP_RETRY_STEP_MS = 500; + +class AuthBootstrapHttpError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "AuthBootstrapHttpError"; + this.status = status; + } +} export function peekPairingTokenFromUrl(): string | null { const url = new URL(window.location.href); @@ -47,32 +65,79 @@ function getDesktopBootstrapCredential(): string | null { } async function fetchSessionState(): Promise { - const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/session" }), { - credentials: "include", + return retryTransientAuthBootstrap(async () => { + const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/session" }), { + credentials: "include", + }); + if (!response.ok) { + throw new AuthBootstrapHttpError( + `Failed to load auth session state (${response.status}).`, + response.status, + ); + } + return (await response.json()) as AuthSessionState; }); - if (!response.ok) { - throw new Error(`Failed to load auth session state (${response.status}).`); - } - return (await response.json()) as AuthSessionState; } async function exchangeBootstrapCredential(credential: string): Promise { - const payload: AuthBootstrapInput = { credential }; - const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/bootstrap" }), { - body: JSON.stringify(payload), - credentials: "include", - headers: { - "content-type": "application/json", - }, - method: "POST", + return retryTransientAuthBootstrap(async () => { + const payload: AuthBootstrapInput = { credential }; + const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/bootstrap" }), { + body: JSON.stringify(payload), + credentials: "include", + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + + if (!response.ok) { + const message = await response.text(); + throw new AuthBootstrapHttpError( + message || `Failed to bootstrap auth session (${response.status}).`, + response.status, + ); + } + + return (await response.json()) as AuthBootstrapResult; }); +} - if (!response.ok) { - const message = await response.text(); - throw new Error(message || `Failed to bootstrap auth session (${response.status}).`); +async function retryTransientAuthBootstrap(operation: () => Promise): Promise { + const startedAt = Date.now(); + while (true) { + try { + return await operation(); + } catch (error) { + if (!isTransientAuthBootstrapError(error)) { + throw error; + } + + if (Date.now() - startedAt >= AUTH_BOOTSTRAP_RETRY_TIMEOUT_MS) { + throw error; + } + + await waitForAuthBootstrapRetry(AUTH_BOOTSTRAP_RETRY_STEP_MS); + } + } +} + +function isTransientAuthBootstrapError(error: unknown): boolean { + if (error instanceof AuthBootstrapHttpError) { + return TRANSIENT_AUTH_BOOTSTRAP_STATUS_CODES.has(error.status); } - return (await response.json()) as AuthBootstrapResult; + if (error instanceof TypeError) { + return true; + } + + return error instanceof DOMException && error.name === "AbortError"; +} + +function waitForAuthBootstrapRetry(delayMs: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); } async function bootstrapServerAuth(): Promise { @@ -112,6 +177,20 @@ export async function submitServerAuthCredential(credential: string): Promise { + const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/pairing-token" }), { + credentials: "include", + method: "POST", + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Failed to create pairing credential (${response.status}).`); + } + + return (await response.json()) as AuthPairingCredentialResult; +} + export function resolveInitialServerAuthGateState(): Promise { if (bootstrapPromise) { return bootstrapPromise; diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index c28538aa1d..2876e85114 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -3,6 +3,8 @@ import "../../index.css"; import { DEFAULT_SERVER_SETTINGS, EnvironmentId, + type DesktopBridge, + type DesktopUpdateState, type LocalApi, type ServerConfig, } from "@t3tools/contracts"; @@ -47,18 +49,72 @@ function createBaseServerConfig(): ServerConfig { }; } +const createDesktopBridgeStub = (overrides?: { + readonly serverExposureState?: Awaited>; +}): DesktopBridge => { + const idleUpdateState: DesktopUpdateState = { + enabled: false, + status: "idle", + currentVersion: "0.0.0-test", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }; + + return { + getWsUrl: () => null, + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + wsUrl: "ws://127.0.0.1:3773/ws", + bootstrapToken: "desktop-bootstrap-token", + }), + getServerExposureState: vi.fn().mockResolvedValue( + overrides?.serverExposureState ?? { + mode: "local-only", + endpointUrl: null, + advertisedHost: null, + }, + ), + setServerExposureMode: vi.fn().mockImplementation(async (mode) => ({ + mode, + endpointUrl: mode === "network-accessible" ? "http://192.168.1.44:3773" : null, + advertisedHost: mode === "network-accessible" ? "192.168.1.44" : null, + })), + pickFolder: vi.fn().mockResolvedValue(null), + confirm: vi.fn().mockResolvedValue(false), + setTheme: vi.fn().mockResolvedValue(undefined), + showContextMenu: vi.fn().mockResolvedValue(null), + openExternal: vi.fn().mockResolvedValue(true), + onMenuAction: () => () => {}, + getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), + checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }), + downloadUpdate: vi + .fn() + .mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }), + installUpdate: vi + .fn() + .mockResolvedValue({ accepted: false, completed: false, state: idleUpdateState }), + onUpdateState: () => () => {}, + }; +}; + describe("GeneralSettingsPanel observability", () => { beforeEach(async () => { resetServerStateForTests(); await __resetLocalApiForTests(); localStorage.clear(); - document.body.innerHTML = ""; }); afterEach(async () => { resetServerStateForTests(); await __resetLocalApiForTests(); - document.body.innerHTML = ""; }); it("shows diagnostics inside About with a single logs-folder action", async () => { @@ -85,6 +141,94 @@ describe("GeneralSettingsPanel observability", () => { .toBeInTheDocument(); }); + it("creates and shows a pairing link when network access is enabled", async () => { + window.desktopBridge = createDesktopBridgeStub({ + serverExposureState: { + mode: "network-accessible", + endpointUrl: "http://192.168.1.44:3773", + advertisedHost: "192.168.1.44", + }, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + credential: "pairing-token", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ), + ); + + setServerConfigSnapshot(createBaseServerConfig()); + + await render( + + + , + ); + + await expect.element(page.getByText("Network access")).toBeInTheDocument(); + await expect.element(page.getByText("Pair another client")).toBeInTheDocument(); + await page.getByText("Create link", { exact: true }).click(); + await expect + .element(page.getByText("http://192.168.1.44:3773/pair?token=pairing-token")) + .toBeInTheDocument(); + await expect.element(page.getByText("Copy URL", { exact: true })).toBeInTheDocument(); + }); + + it("confirms before restarting to change network access", async () => { + let resolveSetServerExposureMode: + | ((value: Awaited>) => void) + | null = null; + const setServerExposureMode = vi + .fn() + .mockImplementation( + () => + new Promise((resolve) => { + resolveSetServerExposureMode = resolve; + }), + ); + window.desktopBridge = { + ...createDesktopBridgeStub(), + setServerExposureMode, + }; + + setServerConfigSnapshot(createBaseServerConfig()); + + await render( + + + , + ); + + await page.getByLabelText("Enable network access").click(); + + expect(setServerExposureMode).not.toHaveBeenCalled(); + await expect.element(page.getByText("Enable network access?")).toBeInTheDocument(); + await expect + .element(page.getByText("T3 Code will restart to expose this environment over the network.")) + .toBeInTheDocument(); + + await page.getByText("Restart and enable").click(); + + expect(setServerExposureMode).toHaveBeenCalledWith("network-accessible"); + await expect.element(page.getByText("Restarting…")).toBeInTheDocument(); + await expect.element(page.getByText("Enable network access?")).toBeInTheDocument(); + + expect(resolveSetServerExposureMode).toBeTypeOf("function"); + resolveSetServerExposureMode!({ + mode: "network-accessible", + endpointUrl: null, + advertisedHost: null, + }); + }); + it("opens the logs folder in the preferred editor", async () => { const openInEditor = vi.fn().mockResolvedValue(undefined); window.nativeApi = { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6251db7cd9..4f9fd28688 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -13,6 +13,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, + type DesktopServerExposureState, type ScopedThreadRef, type ProviderKind, type ServerProvider, @@ -23,6 +24,7 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; +import { createServerPairingCredential } from "../../authBootstrap"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -34,6 +36,7 @@ import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { isElectron } from "../../env"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -55,10 +58,20 @@ import { import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Spinner } from "../ui/spinner"; import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -447,6 +460,13 @@ function AboutVersionSection() { ); } +function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { + const url = new URL(endpointUrl); + url.pathname = "/pair"; + url.searchParams.set("token", credential); + return url.toString(); +} + export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); const settings = useSettings(); @@ -781,6 +801,121 @@ export function GeneralSettingsPanel() { serverProviders[0]!.checkedAt, ) : null; + const [desktopServerExposureState, setDesktopServerExposureState] = + useState(null); + const [desktopServerExposureError, setDesktopServerExposureError] = useState(null); + const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); + const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState< + DesktopServerExposureState["mode"] | null + >(null); + const [desktopPairingUrl, setDesktopPairingUrl] = useState(null); + const [isCreatingDesktopPairingUrl, setIsCreatingDesktopPairingUrl] = useState(false); + const desktopBridge = window.desktopBridge; + const { copyToClipboard: copyDesktopPairingUrl, isCopied: isDesktopPairingUrlCopied } = + useCopyToClipboard({ + onCopy: () => { + toastManager.add({ + type: "success", + title: "Pairing URL copied", + description: "Open it in the client you want to pair to this environment.", + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Could not copy pairing URL", + description: error.message, + }); + }, + }); + const handleCopyDesktopPairingUrl = useCallback(() => { + if (!desktopPairingUrl) return; + copyDesktopPairingUrl(desktopPairingUrl, undefined); + }, [copyDesktopPairingUrl, desktopPairingUrl]); + + const createDesktopPairingUrl = useCallback(async (endpointUrl: string) => { + setIsCreatingDesktopPairingUrl(true); + setDesktopServerExposureError(null); + try { + const result = await createServerPairingCredential(); + setDesktopPairingUrl(resolveDesktopPairingUrl(endpointUrl, result.credential)); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create pairing URL."; + setDesktopServerExposureError(message); + toastManager.add({ + type: "error", + title: "Could not create pairing URL", + description: message, + }); + } finally { + setIsCreatingDesktopPairingUrl(false); + } + }, []); + + const handleDesktopServerExposureChange = useCallback( + async (checked: boolean) => { + if (!desktopBridge) return; + + setIsUpdatingDesktopServerExposure(true); + setDesktopServerExposureError(null); + try { + if (!checked) { + setDesktopPairingUrl(null); + } + const nextState = await desktopBridge.setServerExposureMode( + checked ? "network-accessible" : "local-only", + ); + setDesktopServerExposureState(nextState); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to update network exposure."; + setPendingDesktopServerExposureMode(null); + setDesktopServerExposureError(message); + toastManager.add({ + type: "error", + title: "Could not update network access", + description: message, + }); + setIsUpdatingDesktopServerExposure(false); + } + }, + [desktopBridge], + ); + + const handleConfirmDesktopServerExposureChange = useCallback(() => { + if (pendingDesktopServerExposureMode === null) return; + const checked = pendingDesktopServerExposureMode === "network-accessible"; + void handleDesktopServerExposureChange(checked); + }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]); + + const handleCreateDesktopPairingUrl = useCallback(() => { + const endpointUrl = desktopServerExposureState?.endpointUrl; + if (!endpointUrl) return; + void createDesktopPairingUrl(endpointUrl); + }, [createDesktopPairingUrl, desktopServerExposureState?.endpointUrl]); + + useEffect(() => { + if (!desktopBridge) return; + + let cancelled = false; + void desktopBridge + .getServerExposureState() + .then((state) => { + if (cancelled) return; + setDesktopServerExposureState(state); + }) + .catch((error: unknown) => { + if (cancelled) return; + const message = + error instanceof Error ? error.message : "Failed to load network exposure state."; + setDesktopServerExposureError(message); + }); + + return () => { + cancelled = true; + }; + }, [desktopBridge]); + return ( @@ -1445,6 +1580,141 @@ export function GeneralSettingsPanel() { /> + {desktopBridge ? ( + + + + {desktopServerExposureState?.mode === "network-accessible" && + desktopServerExposureState.endpointUrl + ? "This environment is reachable over the network." + : desktopServerExposureState + ? "This environment is currently limited to this machine." + : "Loading network access state..."} + + {desktopServerExposureState?.endpointUrl ? ( + + {desktopServerExposureState.endpointUrl} + + ) : null} + {desktopServerExposureError ? ( + {desktopServerExposureError} + ) : null} + + } + control={ + { + if (isUpdatingDesktopServerExposure) { + return; + } + if (!open) { + setPendingDesktopServerExposureMode(null); + } + }} + > + { + setPendingDesktopServerExposureMode( + checked ? "network-accessible" : "local-only", + ); + }} + aria-label="Enable network access" + /> + + + + {pendingDesktopServerExposureMode === "network-accessible" + ? "Enable network access?" + : "Disable network access?"} + + + {pendingDesktopServerExposureMode === "network-accessible" + ? "T3 Code will restart to expose this environment over the network." + : "T3 Code will restart and limit this environment back to this machine."} + + + + + } + > + Cancel + + + + + + } + /> + + {desktopPairingUrl} + + ) : desktopServerExposureState?.mode === "local-only" ? ( + Enable network access before creating a pairing link. + ) : null + } + control={ +
+ + +
+ } + /> +
+ ) : null} + {isElectron ? ( diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 34b6b49945..cff769cb88 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -134,6 +134,16 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg return { getWsUrl: () => null, getLocalEnvironmentBootstrap: () => null, + getServerExposureState: async () => ({ + mode: "local-only", + endpointUrl: null, + advertisedHost: null, + }), + setServerExposureMode: async () => ({ + mode: "local-only", + endpointUrl: null, + advertisedHost: null, + }), pickFolder: async () => null, confirm: async () => true, setTheme: async () => undefined, diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index bea9fa25fc..0212fe89db 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -109,6 +109,12 @@ export const AuthBootstrapResult = Schema.Struct({ }); export type AuthBootstrapResult = typeof AuthBootstrapResult.Type; +export const AuthPairingCredentialResult = Schema.Struct({ + credential: TrimmedNonEmptyString, + expiresAt: Schema.DateTimeUtc, +}); +export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Type; + export const AuthSessionState = Schema.Struct({ authenticated: Schema.Boolean, auth: ServerAuthDescriptor, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 815cd4b140..db52e52a40 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -111,9 +111,19 @@ export interface DesktopEnvironmentBootstrap { bootstrapToken?: string; } +export type DesktopServerExposureMode = "local-only" | "network-accessible"; + +export interface DesktopServerExposureState { + mode: DesktopServerExposureMode; + endpointUrl: string | null; + advertisedHost: string | null; +} + export interface DesktopBridge { getWsUrl: () => string | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; + getServerExposureState: () => Promise; + setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; From dc6956423db460313501a9745fe918dc1ebf53a8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 7 Apr 2026 16:20:18 -0700 Subject: [PATCH 16/16] Add persistent auth pairing and session management - Store pairing links and auth sessions in SQLite - Add revoke/list flows for clients and bootstrap links - Surface connection management in settings --- .../Layers/BootstrapCredentialService.test.ts | 30 +- .../auth/Layers/BootstrapCredentialService.ts | 169 +- .../server/src/auth/Layers/ServerAuth.test.ts | 76 +- apps/server/src/auth/Layers/ServerAuth.ts | 140 +- .../Layers/SessionCredentialService.test.ts | 31 + .../auth/Layers/SessionCredentialService.ts | 339 +++- .../Services/BootstrapCredentialService.ts | 24 +- apps/server/src/auth/Services/ServerAuth.ts | 22 +- .../auth/Services/SessionCredentialService.ts | 29 +- apps/server/src/auth/http.ts | 116 +- apps/server/src/http.ts | 8 +- apps/server/src/persistence/Errors.ts | 2 + .../persistence/Layers/AuthPairingLinks.ts | 204 +++ .../src/persistence/Layers/AuthSessions.ts | 185 ++ apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/020_AuthAccessManagement.ts | 42 + .../persistence/Services/AuthPairingLinks.ts | 74 + .../src/persistence/Services/AuthSessions.ts | 71 + apps/server/src/server.test.ts | 184 +- apps/server/src/server.ts | 15 +- apps/server/src/ws.ts | 1553 +++++++++-------- apps/web/package.json | 1 + apps/web/src/authBootstrap.test.ts | 75 +- apps/web/src/authBootstrap.ts | 141 +- .../settings/ConnectionsSettings.tsx | 782 +++++++++ .../settings/SettingsPanels.browser.tsx | 305 +++- .../components/settings/SettingsPanels.tsx | 386 +--- .../settings/SettingsSidebarNav.tsx | 8 +- .../components/settings/settingsLayout.tsx | 119 ++ apps/web/src/localApi.test.ts | 1 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.connections.tsx | 7 + apps/web/src/timestampFormat.test.ts | 64 +- apps/web/src/timestampFormat.ts | 65 + apps/web/src/wsRpcClient.ts | 7 + bun.lock | 3 + packages/contracts/src/auth.ts | 101 +- packages/contracts/src/baseSchemas.ts | 2 + packages/contracts/src/rpc.ts | 9 + 39 files changed, 4169 insertions(+), 1244 deletions(-) create mode 100644 apps/server/src/persistence/Layers/AuthPairingLinks.ts create mode 100644 apps/server/src/persistence/Layers/AuthSessions.ts create mode 100644 apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts create mode 100644 apps/server/src/persistence/Services/AuthPairingLinks.ts create mode 100644 apps/server/src/persistence/Services/AuthSessions.ts create mode 100644 apps/web/src/components/settings/ConnectionsSettings.tsx create mode 100644 apps/web/src/components/settings/settingsLayout.tsx create mode 100644 apps/web/src/routes/settings.connections.tsx diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts index 15d39288fa..7b660df3b2 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts @@ -4,6 +4,7 @@ import { Effect, Layer } from "effect"; import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; @@ -25,7 +26,11 @@ const makeServerConfigLayer = ( const makeBootstrapCredentialLayer = ( overrides?: Partial>, -) => BootstrapCredentialServiceLive.pipe(Layer.provide(makeServerConfigLayer(overrides))); +) => + BootstrapCredentialServiceLive.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(makeServerConfigLayer(overrides)), + ); it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { it.effect("issues one-time bootstrap tokens that can only be consumed once", () => @@ -78,6 +83,7 @@ it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { expect(first.role).toBe("owner"); expect(first.subject).toBe("desktop-bootstrap"); expect(second._tag).toBe("BootstrapCredentialError"); + expect(second.status).toBe(401); }).pipe( Effect.provide( makeBootstrapCredentialLayer({ @@ -86,4 +92,26 @@ it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { ), ), ); + + it.effect("lists and revokes active pairing links", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const first = yield* bootstrapCredentials.issueOneTimeToken(); + const second = yield* bootstrapCredentials.issueOneTimeToken({ role: "owner" }); + + const activeBeforeRevoke = yield* bootstrapCredentials.listActive(); + expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id); + expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id); + + const revoked = yield* bootstrapCredentials.revoke(first.id); + const activeAfterRevoke = yield* bootstrapCredentials.listActive(); + const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential)); + + expect(revoked).toBe(true); + expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); + expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); + expect(revokedConsume.message).toContain("no longer available"); + expect(revokedConsume.status).toBe(401); + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); }); diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts index cab92f0460..09ac34e4ee 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -1,11 +1,17 @@ -import { Effect, Layer, Ref, DateTime, Duration } from "effect"; +import type { AuthPairingLink } from "@t3tools/contracts"; +import { DateTime, Duration, Effect, Layer, PubSub, Ref, Stream } from "effect"; +import { Option } from "effect"; import { ServerConfig } from "../../config.ts"; +import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts"; +import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts"; import { BootstrapCredentialError, BootstrapCredentialService, + type BootstrapCredentialChange, type BootstrapCredentialServiceShape, type BootstrapGrant, + type IssuedBootstrapCredential, } from "../Services/BootstrapCredentialService.ts"; interface StoredBootstrapGrant extends BootstrapGrant { @@ -25,15 +31,42 @@ type ConsumeResult = const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); export const makeBootstrapCredentialService = Effect.gen(function* () { const config = yield* ServerConfig; - const grantsRef = yield* Ref.make(new Map()); + const pairingLinks = yield* AuthPairingLinkRepository; + const seededGrantsRef = yield* Ref.make(new Map()); + const changesPubSub = yield* PubSub.unbounded(); + + const invalidBootstrapCredentialError = (message: string) => + new BootstrapCredentialError({ + message, + status: 401, + }); + + const internalBootstrapCredentialError = (message: string, cause: unknown) => + new BootstrapCredentialError({ + message, + status: 500, + cause, + }); const seedGrant = (credential: string, grant: StoredBootstrapGrant) => - Ref.update(grantsRef, (current) => { + Ref.update(seededGrantsRef, (current) => { const next = new Map(current); next.set(credential, grant); return next; }); + const emitUpsert = (pairingLink: AuthPairingLink) => + PubSub.publish(changesPubSub, { + type: "pairingLinkUpserted", + pairingLink, + }).pipe(Effect.asVoid); + + const emitRemoved = (id: string) => + PubSub.publish(changesPubSub, { + type: "pairingLinkRemoved", + id, + }).pipe(Effect.asVoid); + if (config.desktopBootstrapToken) { const now = yield* DateTime.now; yield* seedGrant(config.desktopBootstrapToken, { @@ -47,38 +80,84 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { }); } + const toBootstrapCredentialError = (message: string) => (cause: unknown) => + internalBootstrapCredentialError(message, cause); + + const listActive: BootstrapCredentialServiceShape["listActive"] = () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const rows = yield* pairingLinks.listActive({ now }); + + return rows.map( + (row) => + ({ + id: row.id, + credential: row.credential, + role: row.role, + subject: row.subject, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + }) satisfies AuthPairingLink, + ); + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links."))); + + const revoke: BootstrapCredentialServiceShape["revoke"] = (id) => + Effect.gen(function* () { + const revokedAt = yield* DateTime.now; + const revoked = yield* pairingLinks.revoke({ + id, + revokedAt, + }); + if (revoked) { + yield* emitRemoved(id); + } + return revoked; + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link."))); + const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) => Effect.gen(function* () { + const id = crypto.randomUUID(); const credential = crypto.randomUUID(); const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; const now = yield* DateTime.now; - yield* seedGrant(credential, { + const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); + const issued: IssuedBootstrapCredential = { + id, + credential, + expiresAt, + }; + yield* pairingLinks.create({ + id, + credential, method: "one-time-token", role: input?.role ?? "client", subject: input?.subject ?? "one-time-token", - expiresAt: DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }), - remainingUses: 1, + createdAt: now, + expiresAt: expiresAt, }); - return { + yield* emitUpsert({ + id, credential, - expiresAt: DateTime.toUtc(DateTime.add(now, { milliseconds: Duration.toMillis(ttl) })), - } as const; - }); + role: input?.role ?? "client", + subject: input?.subject ?? "one-time-token", + createdAt: now, + expiresAt, + }); + return issued; + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential."))); const consume: BootstrapCredentialServiceShape["consume"] = (credential) => Effect.gen(function* () { const now = yield* DateTime.now; - const result: ConsumeResult = yield* Ref.modify( - grantsRef, + const seededResult: ConsumeResult = yield* Ref.modify( + seededGrantsRef, (current): readonly [ConsumeResult, Map] => { const grant = current.get(credential); if (!grant) { return [ { _tag: "error", - error: new BootstrapCredentialError({ - message: "Unknown bootstrap credential.", - }), + error: invalidBootstrapCredentialError("Unknown bootstrap credential."), }, current, ]; @@ -90,9 +169,7 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { return [ { _tag: "error", - error: new BootstrapCredentialError({ - message: "Bootstrap credential expired.", - }), + error: invalidBootstrapCredentialError("Bootstrap credential expired."), }, next, ]; @@ -125,15 +202,61 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { }, ); - if (result._tag === "error") { - return yield* result.error; + if (seededResult._tag === "success") { + return seededResult.grant; } - return result.grant; - }); + const consumed = yield* pairingLinks.consumeAvailable({ + credential, + consumedAt: now, + now, + }); + + if (Option.isSome(consumed)) { + yield* emitRemoved(consumed.value.id); + return { + method: consumed.value.method, + role: consumed.value.role, + subject: consumed.value.subject, + expiresAt: consumed.value.expiresAt, + } satisfies BootstrapGrant; + } + + const matching = yield* pairingLinks.getByCredential({ credential }); + if (Option.isNone(matching)) { + return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + } + + if (matching.value.revokedAt !== null) { + return yield* invalidBootstrapCredentialError( + "Bootstrap credential is no longer available.", + ); + } + + if (matching.value.consumedAt !== null) { + return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + } + + if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { + return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); + } + + return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); + }).pipe( + Effect.mapError((cause) => + cause instanceof BootstrapCredentialError + ? cause + : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), + ), + ); return { issueOneTimeToken, + listActive, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + revoke, consume, } satisfies BootstrapCredentialServiceShape; }); @@ -141,4 +264,4 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { export const BootstrapCredentialServiceLive = Layer.effect( BootstrapCredentialService, makeBootstrapCredentialService, -); +).pipe(Layer.provideMerge(AuthPairingLinkRepositoryLive)); diff --git a/apps/server/src/auth/Layers/ServerAuth.test.ts b/apps/server/src/auth/Layers/ServerAuth.test.ts index 815acf8217..91408007a6 100644 --- a/apps/server/src/auth/Layers/ServerAuth.test.ts +++ b/apps/server/src/auth/Layers/ServerAuth.test.ts @@ -4,8 +4,10 @@ import { Effect, Layer } from "effect"; import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts"; -import { ServerAuthLive } from "./ServerAuth.ts"; +import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts"; import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; const makeServerConfigLayer = (overrides?: Partial) => @@ -22,6 +24,7 @@ const makeServerConfigLayer = (overrides?: Partial) => const makeServerAuthLayer = (overrides?: Partial) => ServerAuthLive.pipe( + Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive), Layer.provide(makeServerConfigLayer(overrides)), ); @@ -37,6 +40,35 @@ const makeCookieRequest = ( }) as unknown as Parameters[0]; it.layer(NodeServices.layer)("ServerAuthLive", (it) => { + it.effect("maps invalid bootstrap credential failures to 401", () => + Effect.sync(() => { + const error = toBootstrapExchangeAuthError( + new BootstrapCredentialError({ + message: "Unknown bootstrap credential.", + status: 401, + }), + ); + + expect(error.status).toBe(401); + expect(error.message).toBe("Invalid bootstrap credential."); + }), + ); + + it.effect("maps unexpected bootstrap failures to 500", () => + Effect.sync(() => { + const error = toBootstrapExchangeAuthError( + new BootstrapCredentialError({ + message: "Failed to consume bootstrap credential.", + status: 500, + cause: new Error("sqlite is unavailable"), + }), + ); + + expect(error.status).toBe(500); + expect(error.message).toBe("Failed to validate bootstrap credential."); + }), + ); + it.effect("issues client pairing credentials by default", () => Effect.gen(function* () { const serverAuth = yield* ServerAuth; @@ -47,6 +79,7 @@ it.layer(NodeServices.layer)("ServerAuthLive", (it) => { makeCookieRequest(exchanged.sessionToken), ); + expect(verified.sessionId.length).toBeGreaterThan(0); expect(verified.role).toBe("client"); expect(verified.subject).toBe("one-time-token"); }).pipe(Effect.provide(makeServerAuthLayer())), @@ -69,4 +102,45 @@ it.layer(NodeServices.layer)("ServerAuthLive", (it) => { expect(verified.subject).toBe("owner-bootstrap"); }).pipe(Effect.provide(makeServerAuthLayer())), ); + + it.effect("lists pairing links and revokes other client sessions while keeping the owner", () => + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + + const ownerExchange = + yield* serverAuth.exchangeBootstrapCredential("desktop-bootstrap-token"); + const ownerSession = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(ownerExchange.sessionToken), + ); + const pairingCredential = yield* serverAuth.issuePairingCredential(); + const listedPairingLinks = yield* serverAuth.listPairingLinks(); + const clientExchange = yield* serverAuth.exchangeBootstrapCredential( + pairingCredential.credential, + ); + const clientSession = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(clientExchange.sessionToken), + ); + const clientsBeforeRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); + const revokedCount = yield* serverAuth.revokeOtherClientSessions(ownerSession.sessionId); + const clientsAfterRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); + + expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id); + expect(clientsBeforeRevoke).toHaveLength(2); + expect( + clientsBeforeRevoke.find((entry) => entry.sessionId === ownerSession.sessionId)?.current, + ).toBe(true); + expect( + clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current, + ).toBe(false); + expect(revokedCount).toBe(1); + expect(clientsAfterRevoke).toHaveLength(1); + expect(clientsAfterRevoke[0]?.sessionId).toBe(ownerSession.sessionId); + }).pipe( + Effect.provide( + makeServerAuthLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); }); diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 6330f702bd..5790a2b0de 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -1,4 +1,5 @@ import { + type AuthClientSession, type AuthBootstrapResult, type AuthPairingCredentialResult, type AuthSessionState, @@ -10,6 +11,7 @@ import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts" import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; import { ServerAuth, @@ -26,6 +28,22 @@ type BootstrapExchangeResult = { const AUTHORIZATION_PREFIX = "Bearer "; +export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError { + if (cause.status === 500) { + return new AuthError({ + message: "Failed to validate bootstrap credential.", + status: 500, + cause, + }); + } + + return new AuthError({ + message: "Invalid bootstrap credential.", + status: 401, + cause, + }); +} + function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { const header = request.headers["authorization"]; if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { @@ -44,6 +62,7 @@ export const makeServerAuth = Effect.gen(function* () { const authenticateToken = (token: string): Effect.Effect => sessions.verify(token).pipe( Effect.map((session) => ({ + sessionId: session.sessionId, subject: session.subject, method: session.method, role: session.role, @@ -53,6 +72,7 @@ export const makeServerAuth = Effect.gen(function* () { (cause) => new AuthError({ message: "Unauthorized request.", + status: 401, cause, }), ), @@ -66,6 +86,7 @@ export const makeServerAuth = Effect.gen(function* () { return Effect.fail( new AuthError({ message: "Authentication required.", + status: 401, }), ); } @@ -79,6 +100,7 @@ export const makeServerAuth = Effect.gen(function* () { ({ authenticated: true, auth: descriptor, + role: session.role, sessionMethod: session.method, ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), }) satisfies AuthSessionState, @@ -95,25 +117,30 @@ export const makeServerAuth = Effect.gen(function* () { credential, ) => bootstrapCredentials.consume(credential).pipe( - Effect.mapError( - (cause) => - new AuthError({ - message: "Invalid bootstrap credential.", - cause, - }), - ), + Effect.mapError(toBootstrapExchangeAuthError), Effect.flatMap((grant) => - sessions.issue({ - method: "browser-session-cookie", - subject: grant.subject, - role: grant.role, - }), + sessions + .issue({ + method: "browser-session-cookie", + subject: grant.subject, + role: grant.role, + }) + .pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to issue authenticated session.", + cause, + }), + ), + ), ), Effect.map( (session) => ({ response: { authenticated: true, + role: session.role, sessionMethod: session.method, expiresAt: DateTime.toUtc(session.expiresAt), } satisfies AuthBootstrapResult, @@ -129,15 +156,99 @@ export const makeServerAuth = Effect.gen(function* () { subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token", }) .pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to issue pairing credential.", + cause, + }), + ), Effect.map( (issued) => ({ + id: issued.id, credential: issued.credential, expiresAt: issued.expiresAt, }) satisfies AuthPairingCredentialResult, ), ); + const listPairingLinks: ServerAuthShape["listPairingLinks"] = () => + bootstrapCredentials.listActive().pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to load pairing links.", + cause, + }), + ), + ); + + const revokePairingLink: ServerAuthShape["revokePairingLink"] = (id) => + bootstrapCredentials.revoke(id).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to revoke pairing link.", + cause, + }), + ), + ); + + const listClientSessions: ServerAuthShape["listClientSessions"] = (currentSessionId) => + sessions.listActive().pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to load paired clients.", + cause, + }), + ), + Effect.map((clientSessions) => + clientSessions.map( + (clientSession): AuthClientSession => ({ + ...clientSession, + current: clientSession.sessionId === currentSessionId, + }), + ), + ), + ); + + const revokeClientSession: ServerAuthShape["revokeClientSession"] = ( + currentSessionId, + targetSessionId, + ) => + Effect.gen(function* () { + if (currentSessionId === targetSessionId) { + return yield* new AuthError({ + message: "Use revoke other clients to keep the current owner session active.", + status: 403, + }); + } + return yield* sessions.revoke(targetSessionId).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to revoke client session.", + cause, + }), + ), + ); + }); + + const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = ( + currentSessionId, + ) => + sessions.revokeAllExcept(currentSessionId).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to revoke other client sessions.", + cause, + }), + ), + ); + const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) => issuePairingCredential({ role: "owner" }).pipe( Effect.map((issued) => { @@ -153,6 +264,11 @@ export const makeServerAuth = Effect.gen(function* () { getSessionState, exchangeBootstrapCredential, issuePairingCredential, + listPairingLinks, + revokePairingLink, + listClientSessions, + revokeClientSession, + revokeOtherClientSessions, authenticateHttpRequest: authenticateRequest, authenticateWebSocketUpgrade: authenticateRequest, issueStartupPairingUrl, diff --git a/apps/server/src/auth/Layers/SessionCredentialService.test.ts b/apps/server/src/auth/Layers/SessionCredentialService.test.ts index 0ea1a83513..f0fc819895 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.test.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.test.ts @@ -5,6 +5,7 @@ import { TestClock } from "effect/testing"; import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; @@ -27,6 +28,7 @@ const makeSessionCredentialLayer = ( overrides?: Partial>, ) => SessionCredentialServiceLive.pipe( + Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive), Layer.provide(makeServerConfigLayer(overrides)), ); @@ -70,4 +72,33 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { expect(verified.role).toBe("client"); }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), ); + + it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const owner = yield* sessions.issue({ + subject: "desktop-bootstrap", + role: "owner", + }); + const client = yield* sessions.issue({ + subject: "one-time-token", + role: "client", + }); + + yield* sessions.markConnected(client.sessionId); + const beforeRevoke = yield* sessions.listActive(); + const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId); + const afterRevoke = yield* sessions.listActive(); + const revokedClient = yield* Effect.flip(sessions.verify(client.token)); + + expect(beforeRevoke).toHaveLength(2); + expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( + true, + ); + expect(revokedCount).toBe(1); + expect(afterRevoke).toHaveLength(1); + expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId); + expect(revokedClient.message).toContain("revoked"); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); }); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index 17fe3297aa..7945f75557 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -1,10 +1,15 @@ -import { Clock, DateTime, Duration, Effect, Layer, Schema } from "effect"; +import { AuthSessionId, type AuthClientSession } from "@t3tools/contracts"; +import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; +import { Option } from "effect"; +import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; +import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; import { SessionCredentialError, SessionCredentialService, type IssuedSession, + type SessionCredentialChange, type SessionCredentialServiceShape, type VerifiedSession, } from "../Services/SessionCredentialService.ts"; @@ -16,11 +21,13 @@ import { } from "../tokenCodec.ts"; const SIGNING_SECRET_NAME = "server-signing-key"; +const SESSION_COOKIE_NAME = "t3_session"; const DEFAULT_SESSION_TTL = Duration.days(30); const SessionClaims = Schema.Struct({ v: Schema.Literal(1), kind: Schema.Literal("session"), + sid: AuthSessionId, sub: Schema.String, role: Schema.Literals(["owner", "client"]), method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), @@ -29,84 +36,296 @@ const SessionClaims = Schema.Struct({ }); type SessionClaims = typeof SessionClaims.Type; +function toAuthClientSession(input: Omit): AuthClientSession { + return { + ...input, + current: false, + }; +} + export const makeSessionCredentialService = Effect.gen(function* () { const secretStore = yield* ServerSecretStore; + const authSessions = yield* AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); + const connectedSessionsRef = yield* Ref.make(new Map()); + const changesPubSub = yield* PubSub.unbounded(); + + const toSessionCredentialError = (message: string) => (cause: unknown) => + new SessionCredentialError({ + message, + cause, + }); + + const emitUpsert = (clientSession: AuthClientSession) => + PubSub.publish(changesPubSub, { + type: "clientUpserted", + clientSession, + }).pipe(Effect.asVoid); + + const emitRemoved = (sessionId: AuthSessionId) => + PubSub.publish(changesPubSub, { + type: "clientRemoved", + sessionId, + }).pipe(Effect.asVoid); - const issue: SessionCredentialServiceShape["issue"] = Effect.fn("issue")(function* (input) { - const issuedAt = yield* DateTime.now; - const expiresAt = DateTime.add(issuedAt, { - milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), + const loadActiveSession = (sessionId: AuthSessionId) => + Effect.gen(function* () { + const row = yield* authSessions.getById({ sessionId }); + if (Option.isNone(row) || row.value.revokedAt !== null) { + return Option.none(); + } + + const connectedSessions = yield* Ref.get(connectedSessionsRef); + return Option.some( + toAuthClientSession({ + sessionId: row.value.sessionId, + subject: row.value.subject, + role: row.value.role, + method: row.value.method, + issuedAt: row.value.issuedAt, + expiresAt: row.value.expiresAt, + connected: connectedSessions.has(row.value.sessionId), + }), + ); }); - const claims: SessionClaims = { - v: 1, - kind: "session", - sub: input?.subject ?? "browser", - role: input?.role ?? "client", - method: input?.method ?? "browser-session-cookie", - iat: issuedAt.epochMilliseconds, - exp: expiresAt.epochMilliseconds, - }; - const encodedPayload = base64UrlEncode(JSON.stringify(claims)); - const signature = signPayload(encodedPayload, signingSecret); - - return { - token: `${encodedPayload}.${signature}`, - method: claims.method, - expiresAt: expiresAt, - role: claims.role, - } satisfies IssuedSession; - }); - - const verify: SessionCredentialServiceShape["verify"] = Effect.fn("verify")(function* (token) { - const [encodedPayload, signature] = token.split("."); - if (!encodedPayload || !signature) { - return yield* new SessionCredentialError({ - message: "Malformed session token.", + + const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) => + Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + next.set(sessionId, (next.get(sessionId) ?? 0) + 1); + return next; + }).pipe( + Effect.flatMap(() => loadActiveSession(sessionId)), + Effect.flatMap((session) => + Option.isSome(session) ? emitUpsert(session.value) : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logError("Failed to publish connected-session auth update.").pipe( + Effect.annotateLogs({ + sessionId, + cause, + }), + ), + ), + ); + + const markDisconnected: SessionCredentialServiceShape["markDisconnected"] = (sessionId) => + Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + const remaining = (next.get(sessionId) ?? 0) - 1; + if (remaining > 0) { + next.set(sessionId, remaining); + } else { + next.delete(sessionId); + } + return next; + }).pipe( + Effect.flatMap(() => loadActiveSession(sessionId)), + Effect.flatMap((session) => + Option.isSome(session) ? emitUpsert(session.value) : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logError("Failed to publish disconnected-session auth update.").pipe( + Effect.annotateLogs({ + sessionId, + cause, + }), + ), + ), + ); + + const issue: SessionCredentialServiceShape["issue"] = (input) => + Effect.gen(function* () { + const sessionId = AuthSessionId.makeUnsafe(crypto.randomUUID()); + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), + }); + const claims: SessionClaims = { + v: 1, + kind: "session", + sid: sessionId, + sub: input?.subject ?? "browser", + role: input?.role ?? "client", + method: input?.method ?? "browser-session-cookie", + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = base64UrlEncode(JSON.stringify(claims)); + const signature = signPayload(encodedPayload, signingSecret); + yield* authSessions.create({ + sessionId, + subject: claims.sub, + role: claims.role, + method: claims.method, + issuedAt, + expiresAt, }); - } + yield* emitUpsert( + toAuthClientSession({ + sessionId, + subject: claims.sub, + role: claims.role, + method: claims.method, + issuedAt, + expiresAt, + connected: false, + }), + ); + + return { + sessionId, + token: `${encodedPayload}.${signature}`, + method: claims.method, + expiresAt: expiresAt, + role: claims.role, + } satisfies IssuedSession; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential."))); + + const verify: SessionCredentialServiceShape["verify"] = (token) => + Effect.gen(function* () { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new SessionCredentialError({ + message: "Malformed session token.", + }); + } - const expectedSignature = signPayload(encodedPayload, signingSecret); - if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new SessionCredentialError({ - message: "Invalid session token signature.", + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new SessionCredentialError({ + message: "Invalid session token signature.", + }); + } + + const claims = yield* Effect.try({ + try: () => + Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))), + catch: (cause) => + new SessionCredentialError({ + message: "Invalid session token payload.", + cause, + }), }); - } - - const claims = yield* Effect.try({ - try: () => - Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))), - catch: (cause) => - new SessionCredentialError({ - message: "Invalid session token payload.", - cause, + + const now = yield* Clock.currentTimeMillis; + if (claims.exp <= now) { + return yield* new SessionCredentialError({ + message: "Session token expired.", + }); + } + + const row = yield* authSessions.getById({ sessionId: claims.sid }); + if (Option.isNone(row)) { + return yield* new SessionCredentialError({ + message: "Unknown session token.", + }); + } + if (row.value.revokedAt !== null) { + return yield* new SessionCredentialError({ + message: "Session token revoked.", + }); + } + + return { + sessionId: claims.sid, + token, + method: claims.method, + expiresAt: DateTime.makeUnsafe(claims.exp), + subject: claims.sub, + role: claims.role, + } satisfies VerifiedSession; + }).pipe( + Effect.mapError((cause) => + cause instanceof SessionCredentialError + ? cause + : new SessionCredentialError({ + message: "Failed to verify session credential.", + cause, + }), + ), + ); + + const listActive: SessionCredentialServiceShape["listActive"] = () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const connectedSessions = yield* Ref.get(connectedSessionsRef); + const rows = yield* authSessions.listActive({ now }); + + return rows.map((row) => + toAuthClientSession({ + sessionId: row.sessionId, + subject: row.subject, + role: row.role, + method: row.method, + issuedAt: row.issuedAt, + expiresAt: row.expiresAt, + connected: connectedSessions.has(row.sessionId), }), - }); + ); + }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions."))); - const now = yield* Clock.currentTimeMillis; - if (claims.exp <= now) { - return yield* new SessionCredentialError({ - message: "Session token expired.", + const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) => + Effect.gen(function* () { + const revokedAt = yield* DateTime.now; + const revoked = yield* authSessions.revoke({ + sessionId, + revokedAt, }); - } + if (revoked) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + next.delete(sessionId); + return next; + }); + yield* emitRemoved(sessionId); + } + return revoked; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session."))); - return { - token, - method: claims.method, - expiresAt: DateTime.makeUnsafe(claims.exp), - subject: claims.sub, - role: claims.role, - } satisfies VerifiedSession; - }); + const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) => + Effect.gen(function* () { + const revokedAt = yield* DateTime.now; + const revokedSessionIds = yield* authSessions.revokeAllExcept({ + currentSessionId: sessionId, + revokedAt, + }); + if (revokedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const revokedSessionId of revokedSessionIds) { + next.delete(revokedSessionId); + } + return next; + }); + yield* Effect.forEach( + revokedSessionIds, + (revokedSessionId) => emitRemoved(revokedSessionId), + { + concurrency: "unbounded", + discard: true, + }, + ); + } + return revokedSessionIds.length; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); return { - cookieName: "t3_session", + cookieName: SESSION_COOKIE_NAME, issue, verify, + listActive, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + revoke, + revokeAllExcept, + markConnected, + markDisconnected, } satisfies SessionCredentialServiceShape; }); export const SessionCredentialServiceLive = Layer.effect( SessionCredentialService, makeSessionCredentialService, -); +).pipe(Layer.provideMerge(AuthSessionRepositoryLive)); diff --git a/apps/server/src/auth/Services/BootstrapCredentialService.ts b/apps/server/src/auth/Services/BootstrapCredentialService.ts index b3061d82a7..39d028335d 100644 --- a/apps/server/src/auth/Services/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Services/BootstrapCredentialService.ts @@ -1,6 +1,6 @@ -import type { ServerAuthBootstrapMethod } from "@t3tools/contracts"; +import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts"; import { Data, DateTime, Duration, ServiceMap } from "effect"; -import type { Effect } from "effect"; +import type { Effect, Stream } from "effect"; export type BootstrapCredentialRole = "owner" | "client"; @@ -13,20 +13,38 @@ export interface BootstrapGrant { export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{ readonly message: string; + readonly status?: 401 | 500; readonly cause?: unknown; }> {} export interface IssuedBootstrapCredential { + readonly id: string; readonly credential: string; readonly expiresAt: DateTime.Utc; } +export type BootstrapCredentialChange = + | { + readonly type: "pairingLinkUpserted"; + readonly pairingLink: AuthPairingLink; + } + | { + readonly type: "pairingLinkRemoved"; + readonly id: string; + }; + export interface BootstrapCredentialServiceShape { readonly issueOneTimeToken: (input?: { readonly ttl?: Duration.Duration; readonly role?: BootstrapCredentialRole; readonly subject?: string; - }) => Effect.Effect; + }) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + BootstrapCredentialError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: (id: string) => Effect.Effect; readonly consume: (credential: string) => Effect.Effect; } diff --git a/apps/server/src/auth/Services/ServerAuth.ts b/apps/server/src/auth/Services/ServerAuth.ts index f1315dbb9e..585ac294fd 100644 --- a/apps/server/src/auth/Services/ServerAuth.ts +++ b/apps/server/src/auth/Services/ServerAuth.ts @@ -1,6 +1,9 @@ import type { AuthBootstrapResult, + AuthClientSession, + AuthPairingLink, AuthPairingCredentialResult, + AuthSessionId, AuthSessionState, ServerAuthDescriptor, ServerAuthSessionMethod, @@ -11,6 +14,7 @@ import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest" import type { SessionRole } from "./SessionCredentialService.ts"; export interface AuthenticatedSession { + readonly sessionId: AuthSessionId; readonly subject: string; readonly method: ServerAuthSessionMethod; readonly role: SessionRole; @@ -19,7 +23,7 @@ export interface AuthenticatedSession { export class AuthError extends Data.TaggedError("AuthError")<{ readonly message: string; - readonly status?: 401 | 403; + readonly status?: 400 | 401 | 403 | 500; readonly cause?: unknown; }> {} @@ -37,14 +41,26 @@ export interface ServerAuthShape { >; readonly issuePairingCredential: (input?: { readonly role?: SessionRole; - }) => Effect.Effect; + }) => Effect.Effect; + readonly listPairingLinks: () => Effect.Effect, AuthError>; + readonly revokePairingLink: (id: string) => Effect.Effect; + readonly listClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect, AuthError>; + readonly revokeClientSession: ( + currentSessionId: AuthSessionId, + targetSessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect; readonly authenticateHttpRequest: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; readonly authenticateWebSocketUpgrade: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; - readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect; + readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect; } export class ServerAuth extends ServiceMap.Service()( diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts index de467ce0b7..ea30384e5d 100644 --- a/apps/server/src/auth/Services/SessionCredentialService.ts +++ b/apps/server/src/auth/Services/SessionCredentialService.ts @@ -1,10 +1,11 @@ -import type { ServerAuthSessionMethod } from "@t3tools/contracts"; +import type { AuthClientSession, AuthSessionId, ServerAuthSessionMethod } from "@t3tools/contracts"; import { Data, DateTime, Duration, ServiceMap } from "effect"; -import type { Effect } from "effect"; +import type { Effect, Stream } from "effect"; export type SessionRole = "owner" | "client"; export interface IssuedSession { + readonly sessionId: AuthSessionId; readonly token: string; readonly method: ServerAuthSessionMethod; readonly expiresAt: DateTime.DateTime; @@ -12,6 +13,7 @@ export interface IssuedSession { } export interface VerifiedSession { + readonly sessionId: AuthSessionId; readonly token: string; readonly method: ServerAuthSessionMethod; readonly expiresAt?: DateTime.DateTime; @@ -19,6 +21,16 @@ export interface VerifiedSession { readonly role: SessionRole; } +export type SessionCredentialChange = + | { + readonly type: "clientUpserted"; + readonly clientSession: AuthClientSession; + } + | { + readonly type: "clientRemoved"; + readonly sessionId: AuthSessionId; + }; + export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{ readonly message: string; readonly cause?: unknown; @@ -31,8 +43,19 @@ export interface SessionCredentialServiceShape { readonly subject?: string; readonly method?: ServerAuthSessionMethod; readonly role?: SessionRole; - }) => Effect.Effect; + }) => Effect.Effect; readonly verify: (token: string) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + SessionCredentialError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: (sessionId: AuthSessionId) => Effect.Effect; + readonly revokeAllExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; } export class SessionCredentialService extends ServiceMap.Service< diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index f0d07f32a2..31876b3728 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -1,16 +1,28 @@ -import { AuthBootstrapInput } from "@t3tools/contracts"; +import { + AuthBootstrapInput, + AuthRevokeClientSessionInput, + AuthRevokePairingLinkInput, +} from "@t3tools/contracts"; import { DateTime, Effect } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; -export const toUnauthorizedResponse = (error: AuthError) => - HttpServerResponse.jsonUnsafe( - { - error: error.message, - }, - { status: error.status ?? 401 }, - ); +export const respondToAuthError = (error: AuthError) => + Effect.gen(function* () { + if ((error.status ?? 500) >= 500) { + yield* Effect.logError("auth route failed", { + message: error.message, + cause: error.cause, + }); + } + return HttpServerResponse.jsonUnsafe( + { + error: error.message, + }, + { status: error.status ?? 500 }, + ); + }); export const authSessionRouteLayer = HttpRouter.add( "GET", @@ -34,6 +46,7 @@ export const authBootstrapRouteLayer = HttpRouter.add( (cause) => new AuthError({ message: "Invalid bootstrap payload.", + status: 400, cause, }), ), @@ -48,7 +61,7 @@ export const authBootstrapRouteLayer = HttpRouter.add( sameSite: "lax", }), ); - }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), ); export const authPairingCredentialRouteLayer = HttpRouter.add( @@ -66,5 +79,88 @@ export const authPairingCredentialRouteLayer = HttpRouter.add( } const result = yield* serverAuth.issuePairingCredential(); return HttpServerResponse.jsonUnsafe(result, { status: 200 }); - }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +const authenticateOwnerSession = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.authenticateHttpRequest(request); + if (session.role !== "owner") { + return yield* new AuthError({ + message: "Only owner sessions can manage network access.", + status: 403, + }); + } + return { serverAuth, session } as const; +}); + +export const authPairingLinksRouteLayer = HttpRouter.add( + "GET", + "/api/auth/pairing-links", + Effect.gen(function* () { + const { serverAuth } = yield* authenticateOwnerSession; + const pairingLinks = yield* serverAuth.listPairingLinks(); + return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authPairingLinksRevokeRouteLayer = HttpRouter.add( + "POST", + "/api/auth/pairing-links/revoke", + Effect.gen(function* () { + const { serverAuth } = yield* authenticateOwnerSession; + const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokePairingLinkInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid revoke pairing link payload.", + status: 400, + cause, + }), + ), + ); + const revoked = yield* serverAuth.revokePairingLink(payload.id); + return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authClientsRouteLayer = HttpRouter.add( + "GET", + "/api/auth/clients", + Effect.gen(function* () { + const { serverAuth, session } = yield* authenticateOwnerSession; + const clients = yield* serverAuth.listClientSessions(session.sessionId); + return HttpServerResponse.jsonUnsafe(clients, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authClientsRevokeRouteLayer = HttpRouter.add( + "POST", + "/api/auth/clients/revoke", + Effect.gen(function* () { + const { serverAuth, session } = yield* authenticateOwnerSession; + const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid revoke client payload.", + status: 400, + cause, + }), + ), + ); + const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId); + return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authClientsRevokeOthersRouteLayer = HttpRouter.add( + "POST", + "/api/auth/clients/revoke-others", + Effect.gen(function* () { + const { serverAuth, session } = yield* authenticateOwnerSession; + const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); + return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c270d985dd..c64aad15b9 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -22,7 +22,7 @@ import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; -import { toUnauthorizedResponse } from "./auth/http.ts"; +import { respondToAuthError } from "./auth/http.ts"; const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; const FALLBACK_PROJECT_FAVICON_SVG = ``; @@ -102,7 +102,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), ), ); - }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ).pipe( Layer.provide( HttpRouter.cors({ @@ -166,7 +166,7 @@ export const attachmentsRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); - }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const projectFaviconRouteLayer = HttpRouter.add( @@ -207,7 +207,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); - }).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))), + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const staticAndDevRouteLayer = HttpRouter.add( diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index cb1cb2f3f8..eb05bf5ae9 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -101,5 +101,7 @@ export type OrchestrationCommandReceiptRepositoryError = | PersistenceDecodeError; export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/Layers/AuthPairingLinks.ts new file mode 100644 index 0000000000..2b5b9b65ea --- /dev/null +++ b/apps/server/src/persistence/Layers/AuthPairingLinks.ts @@ -0,0 +1,204 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema } from "effect"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type AuthPairingLinkRepositoryError, +} from "../Errors.ts"; +import { + AuthPairingLinkRecord, + AuthPairingLinkRepository, + type AuthPairingLinkRepositoryShape, + ConsumeAuthPairingLinkInput, + CreateAuthPairingLinkInput, + GetAuthPairingLinkByCredentialInput, + ListActiveAuthPairingLinksInput, + RevokeAuthPairingLinkInput, +} from "../Services/AuthPairingLinks.ts"; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): AuthPairingLinkRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeAuthPairingLinkRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const createPairingLinkRow = SqlSchema.void({ + Request: CreateAuthPairingLinkInput, + execute: (input) => + sql` + INSERT INTO auth_pairing_links ( + id, + credential, + method, + role, + subject, + created_at, + expires_at, + consumed_at, + revoked_at + ) + VALUES ( + ${input.id}, + ${input.credential}, + ${input.method}, + ${input.role}, + ${input.subject}, + ${input.createdAt}, + ${input.expiresAt}, + NULL, + NULL + ) + `, + }); + + const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ + Request: ConsumeAuthPairingLinkInput, + Result: AuthPairingLinkRecord, + execute: ({ credential, consumedAt, now }) => + sql` + UPDATE auth_pairing_links + SET consumed_at = ${consumedAt} + WHERE credential = ${credential} + AND revoked_at IS NULL + AND consumed_at IS NULL + AND expires_at > ${now} + RETURNING + id AS "id", + credential AS "credential", + method AS "method", + role AS "role", + subject AS "subject", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + `, + }); + + const listActivePairingLinkRows = SqlSchema.findAll({ + Request: ListActiveAuthPairingLinksInput, + Result: AuthPairingLinkRecord, + execute: ({ now }) => + sql` + SELECT + id AS "id", + credential AS "credential", + method AS "method", + role AS "role", + subject AS "subject", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + FROM auth_pairing_links + WHERE revoked_at IS NULL + AND consumed_at IS NULL + AND expires_at > ${now} + ORDER BY created_at DESC, id DESC + `, + }); + + const revokePairingLinkRow = SqlSchema.findAll({ + Request: RevokeAuthPairingLinkInput, + Result: Schema.Struct({ id: Schema.String }), + execute: ({ id, revokedAt }) => + sql` + UPDATE auth_pairing_links + SET revoked_at = ${revokedAt} + WHERE id = ${id} + AND revoked_at IS NULL + AND consumed_at IS NULL + RETURNING id AS "id" + `, + }); + + const getPairingLinkRowByCredential = SqlSchema.findOneOption({ + Request: GetAuthPairingLinkByCredentialInput, + Result: AuthPairingLinkRecord, + execute: ({ credential }) => + sql` + SELECT + id AS "id", + credential AS "credential", + method AS "method", + role AS "role", + subject AS "subject", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + FROM auth_pairing_links + WHERE credential = ${credential} + `, + }); + + const create: AuthPairingLinkRepositoryShape["create"] = (input) => + createPairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.create:query", + "AuthPairingLinkRepository.create:encodeRequest", + ), + ), + ); + + const consumeAvailable: AuthPairingLinkRepositoryShape["consumeAvailable"] = (input) => + consumeAvailablePairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.consumeAvailable:query", + "AuthPairingLinkRepository.consumeAvailable:decodeRow", + ), + ), + ); + + const listActive: AuthPairingLinkRepositoryShape["listActive"] = (input) => + listActivePairingLinkRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.listActive:query", + "AuthPairingLinkRepository.listActive:decodeRows", + ), + ), + ); + + const revoke: AuthPairingLinkRepositoryShape["revoke"] = (input) => + revokePairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.revoke:query", + "AuthPairingLinkRepository.revoke:decodeRows", + ), + ), + Effect.map((rows) => rows.length > 0), + ); + + const getByCredential: AuthPairingLinkRepositoryShape["getByCredential"] = (input) => + getPairingLinkRowByCredential(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.getByCredential:query", + "AuthPairingLinkRepository.getByCredential:decodeRow", + ), + ), + ); + + return { + create, + consumeAvailable, + listActive, + revoke, + getByCredential, + } satisfies AuthPairingLinkRepositoryShape; +}); + +export const AuthPairingLinkRepositoryLive = Layer.effect( + AuthPairingLinkRepository, + makeAuthPairingLinkRepository, +); diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/Layers/AuthSessions.ts new file mode 100644 index 0000000000..9e27688b63 --- /dev/null +++ b/apps/server/src/persistence/Layers/AuthSessions.ts @@ -0,0 +1,185 @@ +import { AuthSessionId } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema } from "effect"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type AuthSessionRepositoryError, +} from "../Errors.ts"; +import { + AuthSessionRecord, + AuthSessionRepository, + type AuthSessionRepositoryShape, + CreateAuthSessionInput, + GetAuthSessionByIdInput, + ListActiveAuthSessionsInput, + RevokeAuthSessionInput, + RevokeOtherAuthSessionsInput, +} from "../Services/AuthSessions.ts"; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): AuthSessionRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeAuthSessionRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const createSessionRow = SqlSchema.void({ + Request: CreateAuthSessionInput, + execute: (input) => + sql` + INSERT INTO auth_sessions ( + session_id, + subject, + role, + method, + issued_at, + expires_at, + revoked_at + ) + VALUES ( + ${input.sessionId}, + ${input.subject}, + ${input.role}, + ${input.method}, + ${input.issuedAt}, + ${input.expiresAt}, + NULL + ) + `, + }); + + const getSessionRowById = SqlSchema.findOneOption({ + Request: GetAuthSessionByIdInput, + Result: AuthSessionRecord, + execute: ({ sessionId }) => + sql` + SELECT + session_id AS "sessionId", + subject AS "subject", + role AS "role", + method AS "method", + issued_at AS "issuedAt", + expires_at AS "expiresAt", + revoked_at AS "revokedAt" + FROM auth_sessions + WHERE session_id = ${sessionId} + `, + }); + + const listActiveSessionRows = SqlSchema.findAll({ + Request: ListActiveAuthSessionsInput, + Result: AuthSessionRecord, + execute: ({ now }) => + sql` + SELECT + session_id AS "sessionId", + subject AS "subject", + role AS "role", + method AS "method", + issued_at AS "issuedAt", + expires_at AS "expiresAt", + revoked_at AS "revokedAt" + FROM auth_sessions + WHERE revoked_at IS NULL + AND expires_at > ${now} + ORDER BY issued_at DESC, session_id DESC + `, + }); + + const revokeSessionRows = SqlSchema.findAll({ + Request: RevokeAuthSessionInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ sessionId, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE session_id = ${sessionId} + AND revoked_at IS NULL + RETURNING session_id AS "sessionId" + `, + }); + + const revokeOtherSessionRows = SqlSchema.findAll({ + Request: RevokeOtherAuthSessionsInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ currentSessionId, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE session_id <> ${currentSessionId} + AND revoked_at IS NULL + RETURNING session_id AS "sessionId" + `, + }); + + const create: AuthSessionRepositoryShape["create"] = (input) => + createSessionRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.create:query", + "AuthSessionRepository.create:encodeRequest", + ), + ), + ); + + const getById: AuthSessionRepositoryShape["getById"] = (input) => + getSessionRowById(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.getById:query", + "AuthSessionRepository.getById:decodeRow", + ), + ), + ); + + const listActive: AuthSessionRepositoryShape["listActive"] = (input) => + listActiveSessionRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.listActive:query", + "AuthSessionRepository.listActive:decodeRows", + ), + ), + ); + + const revoke: AuthSessionRepositoryShape["revoke"] = (input) => + revokeSessionRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.revoke:query", + "AuthSessionRepository.revoke:decodeRows", + ), + ), + Effect.map((rows) => rows.length > 0), + ); + + const revokeAllExcept: AuthSessionRepositoryShape["revokeAllExcept"] = (input) => + revokeOtherSessionRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.revokeAllExcept:query", + "AuthSessionRepository.revokeAllExcept:decodeRows", + ), + ), + Effect.map((rows) => rows.map((row) => row.sessionId)), + ); + + return { + create, + getById, + listActive, + revoke, + revokeAllExcept, + } satisfies AuthSessionRepositoryShape; +}); + +export const AuthSessionRepositoryLive = Layer.effect( + AuthSessionRepository, + makeAuthSessionRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a03c3c2d18..e3fd86d827 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,6 +32,7 @@ import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; +import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; /** * Migration loader with all migrations defined inline. @@ -63,6 +64,7 @@ export const migrationEntries = [ [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], [19, "ProjectionSnapshotLookupIndexes", Migration0019], + [20, "AuthAccessManagement", Migration0020], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts b/apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts new file mode 100644 index 0000000000..1be7fa80ff --- /dev/null +++ b/apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts @@ -0,0 +1,42 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_pairing_links ( + id TEXT PRIMARY KEY, + credential TEXT NOT NULL UNIQUE, + method TEXT NOT NULL, + role TEXT NOT NULL, + subject TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT, + revoked_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_pairing_links_active + ON auth_pairing_links(revoked_at, consumed_at, expires_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_sessions ( + session_id TEXT PRIMARY KEY, + subject TEXT NOT NULL, + role TEXT NOT NULL, + method TEXT NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_sessions_active + ON auth_sessions(revoked_at, expires_at, issued_at) + `; +}); diff --git a/apps/server/src/persistence/Services/AuthPairingLinks.ts b/apps/server/src/persistence/Services/AuthPairingLinks.ts new file mode 100644 index 0000000000..3a71a5d6ea --- /dev/null +++ b/apps/server/src/persistence/Services/AuthPairingLinks.ts @@ -0,0 +1,74 @@ +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; + +export const AuthPairingLinkRecord = Schema.Struct({ + id: Schema.String, + credential: Schema.String, + method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), + role: Schema.Literals(["owner", "client"]), + subject: Schema.String, + createdAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type; + +export const CreateAuthPairingLinkInput = Schema.Struct({ + id: Schema.String, + credential: Schema.String, + method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), + role: Schema.Literals(["owner", "client"]), + subject: Schema.String, + createdAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; + +export const ConsumeAuthPairingLinkInput = Schema.Struct({ + credential: Schema.String, + consumedAt: Schema.DateTimeUtcFromString, + now: Schema.DateTimeUtcFromString, +}); +export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type; + +export const ListActiveAuthPairingLinksInput = Schema.Struct({ + now: Schema.DateTimeUtcFromString, +}); +export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type; + +export const RevokeAuthPairingLinkInput = Schema.Struct({ + id: Schema.String, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type; + +export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ + credential: Schema.String, +}); +export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; + +export interface AuthPairingLinkRepositoryShape { + readonly create: ( + input: CreateAuthPairingLinkInput, + ) => Effect.Effect; + readonly consumeAvailable: ( + input: ConsumeAuthPairingLinkInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly listActive: ( + input: ListActiveAuthPairingLinksInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly revoke: ( + input: RevokeAuthPairingLinkInput, + ) => Effect.Effect; + readonly getByCredential: ( + input: GetAuthPairingLinkByCredentialInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; +} + +export class AuthPairingLinkRepository extends ServiceMap.Service< + AuthPairingLinkRepository, + AuthPairingLinkRepositoryShape +>()("t3/persistence/Services/AuthPairingLinks/AuthPairingLinkRepository") {} diff --git a/apps/server/src/persistence/Services/AuthSessions.ts b/apps/server/src/persistence/Services/AuthSessions.ts new file mode 100644 index 0000000000..92effeec7d --- /dev/null +++ b/apps/server/src/persistence/Services/AuthSessions.ts @@ -0,0 +1,71 @@ +import { AuthSessionId } from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { AuthSessionRepositoryError } from "../Errors.ts"; + +export const AuthSessionRecord = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + role: Schema.Literals(["owner", "client"]), + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthSessionRecord = typeof AuthSessionRecord.Type; + +export const CreateAuthSessionInput = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + role: Schema.Literals(["owner", "client"]), + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; + +export const GetAuthSessionByIdInput = Schema.Struct({ + sessionId: AuthSessionId, +}); +export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; + +export const ListActiveAuthSessionsInput = Schema.Struct({ + now: Schema.DateTimeUtcFromString, +}); +export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; + +export const RevokeAuthSessionInput = Schema.Struct({ + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; + +export const RevokeOtherAuthSessionsInput = Schema.Struct({ + currentSessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; + +export interface AuthSessionRepositoryShape { + readonly create: ( + input: CreateAuthSessionInput, + ) => Effect.Effect; + readonly getById: ( + input: GetAuthSessionByIdInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly listActive: ( + input: ListActiveAuthSessionsInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly revoke: ( + input: RevokeAuthSessionInput, + ) => Effect.Effect; + readonly revokeAllExcept: ( + input: RevokeOtherAuthSessionsInput, + ) => Effect.Effect, AuthSessionRepositoryError>; +} + +export class AuthSessionRepository extends ServiceMap.Service< + AuthSessionRepository, + AuthSessionRepositoryShape +>()("t3/persistence/Services/AuthSessions/AuthSessionRepository") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3468377a7c..9d88798a1e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -69,6 +69,7 @@ import { type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; +import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { ProviderRegistry, type ProviderRegistryShape, @@ -177,7 +178,10 @@ const browserOtlpTracingLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); -const authTestLayer = ServerAuthLive.pipe(Layer.provide(ServerSecretStoreLive)); +const authTestLayer = ServerAuthLive.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStoreLive), +); const makeBrowserOtlpPayload = (spanName: string) => Effect.gen(function* () { @@ -786,6 +790,56 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("lists and revokes pairing links for owner sessions", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const createdResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + }); + const createdBody = (yield* createdResponse.json) as { + readonly id: string; + readonly credential: string; + }; + + const listResponse = yield* HttpClient.get("/api/auth/pairing-links", { + headers: { + cookie: ownerCookie, + }, + }); + const listedLinks = (yield* listResponse.json) as ReadonlyArray<{ + readonly id: string; + readonly credential: string; + }>; + + const revokeUrl = yield* getHttpServerUrl("/api/auth/pairing-links/revoke"); + const revokeResponse = yield* Effect.promise(() => + fetch(revokeUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ id: createdBody.id }), + }), + ); + const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential); + + assert.equal(createdResponse.status, 200); + assert.equal(listResponse.status, 200); + assert.isTrue(listedLinks.some((entry) => entry.id === createdBody.id)); + assert.equal(revokeResponse.status, 200); + assert.equal(revokedBootstrap.response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects pairing credential requests from non-owner paired sessions", () => Effect.gen(function* () { yield* buildAppUnderTest({ @@ -819,6 +873,134 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("lists paired clients and revokes other sessions while keeping the owner", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const ownerPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + }); + const ownerPairingBody = (yield* ownerPairingResponse.json) as { + readonly credential: string; + }; + const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader( + ownerPairingBody.credential, + ); + + const listBeforeResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { + cookie: ownerCookie, + }, + }); + const clientsBefore = (yield* listBeforeResponse.json) as ReadonlyArray<{ + readonly sessionId: string; + readonly current: boolean; + }>; + const pairedSessionId = clientsBefore.find((entry) => !entry.current)?.sessionId; + + const revokeOthersResponse = yield* HttpClient.post("/api/auth/clients/revoke-others", { + headers: { + cookie: ownerCookie, + }, + }); + const revokeOthersBody = (yield* revokeOthersResponse.json) as { + readonly revokedCount: number; + }; + + const listAfterResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { + cookie: ownerCookie, + }, + }); + const clientsAfter = (yield* listAfterResponse.json) as ReadonlyArray<{ + readonly sessionId: string; + readonly current: boolean; + }>; + + const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: pairedSessionCookie, + }, + }); + const pairedClientPairingBody = (yield* pairedClientPairingResponse.json) as { + readonly error: string; + }; + + assert.equal(listBeforeResponse.status, 200); + assert.lengthOf(clientsBefore, 2); + assert.isDefined(pairedSessionId); + assert.equal(revokeOthersResponse.status, 200); + assert.equal(revokeOthersBody.revokedCount, 1); + assert.equal(listAfterResponse.status, 200); + assert.lengthOf(clientsAfter, 1); + assert.equal(clientsAfter[0]?.current, true); + assert.equal(pairedClientPairingResponse.status, 401); + assert.equal(pairedClientPairingBody.error, "Unauthorized request."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("revokes an individual paired client session", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + }); + const pairingBody = (yield* pairingResponse.json) as { + readonly credential: string; + }; + const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader( + pairingBody.credential, + ); + + const clientsResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { + cookie: ownerCookie, + }, + }); + const clients = (yield* clientsResponse.json) as ReadonlyArray<{ + readonly sessionId: string; + readonly current: boolean; + }>; + const pairedSessionId = clients.find((entry) => !entry.current)?.sessionId; + assert.isDefined(pairedSessionId); + + const revokeUrl = yield* getHttpServerUrl("/api/auth/clients/revoke"); + const revokeResponse = yield* Effect.promise(() => + fetch(revokeUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ sessionId: pairedSessionId }), + }), + ); + const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: pairedSessionCookie, + }, + }); + + assert.equal(revokeResponse.status, 200); + assert.equal(pairedClientPairingResponse.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects reusing the same bootstrap credential after it has been exchanged", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4fb923b48f..32dfcee99e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -53,6 +53,11 @@ import { ObservabilityLive } from "./observability/Layers/Observability"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; import { authBootstrapRouteLayer, + authClientsRevokeOthersRouteLayer, + authClientsRevokeRouteLayer, + authClientsRouteLayer, + authPairingLinksRevokeRouteLayer, + authPairingLinksRouteLayer, authPairingCredentialRouteLayer, authSessionRouteLayer, } from "./auth/http"; @@ -195,7 +200,10 @@ const WorkspaceLayerLive = Layer.mergeAll( ), ); -const AuthLayerLive = ServerAuthLive.pipe(Layer.provide(ServerSecretStoreLive)); +const AuthLayerLive = ServerAuthLive.pipe( + Layer.provideMerge(PersistenceLayerLive), + Layer.provide(ServerSecretStoreLive), +); const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services @@ -226,6 +234,11 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( export const makeRoutesLayer = Layer.mergeAll( authBootstrapRouteLayer, + authClientsRevokeOthersRouteLayer, + authClientsRevokeRouteLayer, + authClientsRouteLayer, + authPairingLinksRevokeRouteLayer, + authPairingLinksRouteLayer, authPairingCredentialRouteLayer, authSessionRouteLayer, attachmentsRouteLayer, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 20ac1e6d8b..8d4f946691 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,5 +1,7 @@ import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"; import { + type AuthAccessStreamEvent, + AuthSessionId, CommandId, EventId, type OrchestrationCommand, @@ -50,788 +52,893 @@ import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptR import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { ServerAuth } from "./auth/Services/ServerAuth"; -import { toUnauthorizedResponse } from "./auth/http"; - -const WsRpcLayer = WsRpcGroup.toLayer( - Effect.gen(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngineService; - const checkpointDiffQuery = yield* CheckpointDiffQuery; - const keybindings = yield* Keybindings; - const open = yield* Open; - const gitManager = yield* GitManager; - const git = yield* GitCore; - const gitStatusBroadcaster = yield* GitStatusBroadcaster; - const terminalManager = yield* TerminalManager; - const providerRegistry = yield* ProviderRegistry; - const config = yield* ServerConfig; - const lifecycleEvents = yield* ServerLifecycleEvents; - const serverSettings = yield* ServerSettingsService; - const startup = yield* ServerRuntimeStartup; - const workspaceEntries = yield* WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const repositoryIdentityResolver = yield* RepositoryIdentityResolver; - const serverEnvironment = yield* ServerEnvironment; - const serverAuth = yield* ServerAuth; - const serverCommandId = (tag: string) => - CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); - - const appendSetupScriptActivity = (input: { - readonly threadId: ThreadId; - readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; - readonly summary: string; - readonly createdAt: string; - readonly payload: Record; - readonly tone: "info" | "error"; - }) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: serverCommandId("setup-script-activity"), - threadId: input.threadId, - activity: { - id: EventId.makeUnsafe(crypto.randomUUID()), - tone: input.tone, - kind: input.kind, - summary: input.summary, - payload: input.payload, - turnId: null, - createdAt: input.createdAt, +import { + BootstrapCredentialService, + type BootstrapCredentialChange, +} from "./auth/Services/BootstrapCredentialService"; +import { + SessionCredentialService, + type SessionCredentialChange, +} from "./auth/Services/SessionCredentialService"; +import { respondToAuthError } from "./auth/http"; + +function toAuthAccessStreamEvent( + change: BootstrapCredentialChange | SessionCredentialChange, + revision: number, + currentSessionId: AuthSessionId, +): AuthAccessStreamEvent { + switch (change.type) { + case "pairingLinkUpserted": + return { + version: 1, + revision, + type: "pairingLinkUpserted", + payload: change.pairingLink, + }; + case "pairingLinkRemoved": + return { + version: 1, + revision, + type: "pairingLinkRemoved", + payload: { id: change.id }, + }; + case "clientUpserted": + return { + version: 1, + revision, + type: "clientUpserted", + payload: { + ...change.clientSession, + current: change.clientSession.sessionId === currentSessionId, }, - createdAt: input.createdAt, - }); + }; + case "clientRemoved": + return { + version: 1, + revision, + type: "clientRemoved", + payload: { sessionId: change.sessionId }, + }; + } +} - const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => - Schema.is(OrchestrationDispatchCommandError)(cause) - ? cause - : new OrchestrationDispatchCommandError({ - message: cause instanceof Error ? cause.message : fallbackMessage, - cause, - }); +const makeWsRpcLayer = (currentSessionId: AuthSessionId) => + WsRpcGroup.toLayer( + Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngineService; + const checkpointDiffQuery = yield* CheckpointDiffQuery; + const keybindings = yield* Keybindings; + const open = yield* Open; + const gitManager = yield* GitManager; + const git = yield* GitCore; + const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const terminalManager = yield* TerminalManager; + const providerRegistry = yield* ProviderRegistry; + const config = yield* ServerConfig; + const lifecycleEvents = yield* ServerLifecycleEvents; + const serverSettings = yield* ServerSettingsService; + const startup = yield* ServerRuntimeStartup; + const workspaceEntries = yield* WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const serverEnvironment = yield* ServerEnvironment; + const serverAuth = yield* ServerAuth; + const bootstrapCredentials = yield* BootstrapCredentialService; + const sessions = yield* SessionCredentialService; + const serverCommandId = (tag: string) => + CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); - const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause) => { - const error = Cause.squash(cause); - return Schema.is(OrchestrationDispatchCommandError)(error) - ? error - : new OrchestrationDispatchCommandError({ - message: - error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", - cause, - }); - }; - - const enrichProjectEvent = ( - event: OrchestrationEvent, - ): Effect.Effect => { - switch (event.type) { - case "project.created": - return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( - Effect.map((repositoryIdentity) => ({ - ...event, - payload: { - ...event.payload, - repositoryIdentity, - }, - })), - ); - case "project.meta-updated": - return Effect.gen(function* () { - const workspaceRoot = - event.payload.workspaceRoot ?? - (yield* orchestrationEngine.getReadModel()).projects.find( - (project) => project.id === event.payload.projectId, - )?.workspaceRoot ?? - null; - if (workspaceRoot === null) { - return event; - } + const loadAuthAccessSnapshot = () => + Effect.all({ + pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie), + clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie), + }); - const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); - return { - ...event, - payload: { - ...event.payload, - repositoryIdentity, - }, - } satisfies OrchestrationEvent; - }); - default: - return Effect.succeed(event); - } - }; + const appendSetupScriptActivity = (input: { + readonly threadId: ThreadId; + readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; + readonly summary: string; + readonly createdAt: string; + readonly payload: Record; + readonly tone: "info" | "error"; + }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("setup-script-activity"), + threadId: input.threadId, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); - const enrichOrchestrationEvents = (events: ReadonlyArray) => - Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => + Schema.is(OrchestrationDispatchCommandError)(cause) + ? cause + : new OrchestrationDispatchCommandError({ + message: cause instanceof Error ? cause.message : fallbackMessage, + cause, + }); - const dispatchBootstrapTurnStart = ( - command: Extract, - ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => - Effect.gen(function* () { - const bootstrap = command.bootstrap; - const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; - let createdThread = false; - let targetProjectId = bootstrap?.createThread?.projectId; - let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; - let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; - - const cleanupCreatedThread = () => - createdThread - ? orchestrationEngine - .dispatch({ - type: "thread.delete", - commandId: serverCommandId("bootstrap-thread-delete"), - threadId: command.threadId, - }) - .pipe(Effect.ignoreCause({ log: true })) - : Effect.void; - - const recordSetupScriptLaunchFailure = (input: { - readonly error: unknown; - readonly requestedAt: string; - readonly worktreePath: string; - }) => { - const detail = - input.error instanceof Error ? input.error.message : "Unknown setup failure."; - return appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.failed", - summary: "Setup script failed to start", - createdAt: input.requestedAt, - payload: { - detail, - worktreePath: input.worktreePath, - }, - tone: "error", - }).pipe( - Effect.ignoreCause({ log: false }), - Effect.flatMap(() => - Effect.logWarning("bootstrap turn start failed to launch setup script", { - threadId: command.threadId, - worktreePath: input.worktreePath, - detail, - }), - ), - ); - }; + const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return Schema.is(OrchestrationDispatchCommandError)(error) + ? error + : new OrchestrationDispatchCommandError({ + message: + error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", + cause, + }); + }; - const recordSetupScriptStarted = (input: { - readonly requestedAt: string; - readonly worktreePath: string; - readonly scriptId: string; - readonly scriptName: string; - readonly terminalId: string; - }) => { - const payload = { - scriptId: input.scriptId, - scriptName: input.scriptName, - terminalId: input.terminalId, - worktreePath: input.worktreePath, - }; - return Effect.all([ - appendSetupScriptActivity({ + const enrichProjectEvent = ( + event: OrchestrationEvent, + ): Effect.Effect => { + switch (event.type) { + case "project.created": + return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( + Effect.map((repositoryIdentity) => ({ + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + })), + ); + case "project.meta-updated": + return Effect.gen(function* () { + const workspaceRoot = + event.payload.workspaceRoot ?? + (yield* orchestrationEngine.getReadModel()).projects.find( + (project) => project.id === event.payload.projectId, + )?.workspaceRoot ?? + null; + if (workspaceRoot === null) { + return event; + } + + const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); + return { + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + } satisfies OrchestrationEvent; + }); + default: + return Effect.succeed(event); + } + }; + + const enrichOrchestrationEvents = (events: ReadonlyArray) => + Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + + const dispatchBootstrapTurnStart = ( + command: Extract, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => + Effect.gen(function* () { + const bootstrap = command.bootstrap; + const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; + let createdThread = false; + let targetProjectId = bootstrap?.createThread?.projectId; + let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; + let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; + + const cleanupCreatedThread = () => + createdThread + ? orchestrationEngine + .dispatch({ + type: "thread.delete", + commandId: serverCommandId("bootstrap-thread-delete"), + threadId: command.threadId, + }) + .pipe(Effect.ignoreCause({ log: true })) + : Effect.void; + + const recordSetupScriptLaunchFailure = (input: { + readonly error: unknown; + readonly requestedAt: string; + readonly worktreePath: string; + }) => { + const detail = + input.error instanceof Error ? input.error.message : "Unknown setup failure."; + return appendSetupScriptActivity({ threadId: command.threadId, - kind: "setup-script.requested", - summary: "Starting setup script", + kind: "setup-script.failed", + summary: "Setup script failed to start", createdAt: input.requestedAt, - payload, - tone: "info", - }), - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.started", - summary: "Setup script started", - createdAt: new Date().toISOString(), - payload, - tone: "info", - }), - ]).pipe( - Effect.asVoid, - Effect.catch((error) => - Effect.logWarning( - "bootstrap turn start launched setup script but failed to record setup activity", - { + payload: { + detail, + worktreePath: input.worktreePath, + }, + tone: "error", + }).pipe( + Effect.ignoreCause({ log: false }), + Effect.flatMap(() => + Effect.logWarning("bootstrap turn start failed to launch setup script", { threadId: command.threadId, worktreePath: input.worktreePath, - scriptId: input.scriptId, - terminalId: input.terminalId, - detail: - error instanceof Error - ? error.message - : "Unknown setup activity dispatch failure.", - }, + detail, + }), ), - ), - ); - }; + ); + }; - const runSetupProgram = () => - bootstrap?.runSetupScript && targetWorktreePath - ? (() => { - const worktreePath = targetWorktreePath; - const requestedAt = new Date().toISOString(); - return projectSetupScriptRunner - .runForThread({ + const recordSetupScriptStarted = (input: { + readonly requestedAt: string; + readonly worktreePath: string; + readonly scriptId: string; + readonly scriptName: string; + readonly terminalId: string; + }) => { + const payload = { + scriptId: input.scriptId, + scriptName: input.scriptName, + terminalId: input.terminalId, + worktreePath: input.worktreePath, + }; + return Effect.all([ + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: input.requestedAt, + payload, + tone: "info", + }), + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.started", + summary: "Setup script started", + createdAt: new Date().toISOString(), + payload, + tone: "info", + }), + ]).pipe( + Effect.asVoid, + Effect.catch((error) => + Effect.logWarning( + "bootstrap turn start launched setup script but failed to record setup activity", + { threadId: command.threadId, - ...(targetProjectId ? { projectId: targetProjectId } : {}), - ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), - worktreePath, - }) - .pipe( - Effect.matchEffect({ - onFailure: (error) => - recordSetupScriptLaunchFailure({ - error, - requestedAt, - worktreePath, - }), - onSuccess: (setupResult) => { - if (setupResult.status !== "started") { - return Effect.void; - } - return recordSetupScriptStarted({ - requestedAt, - worktreePath, - scriptId: setupResult.scriptId, - scriptName: setupResult.scriptName, - terminalId: setupResult.terminalId, - }); - }, - }), - ); - })() - : Effect.void; - - const bootstrapProgram = Effect.gen(function* () { - if (bootstrap?.createThread) { - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: serverCommandId("bootstrap-thread-create"), - threadId: command.threadId, - projectId: bootstrap.createThread.projectId, - title: bootstrap.createThread.title, - modelSelection: bootstrap.createThread.modelSelection, - runtimeMode: bootstrap.createThread.runtimeMode, - interactionMode: bootstrap.createThread.interactionMode, - branch: bootstrap.createThread.branch, - worktreePath: bootstrap.createThread.worktreePath, - createdAt: bootstrap.createThread.createdAt, - }); - createdThread = true; - } - - if (bootstrap?.prepareWorktree) { - const worktree = yield* git.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, - branch: bootstrap.prepareWorktree.baseBranch, - newBranch: bootstrap.prepareWorktree.branch, - path: null, - }); - targetWorktreePath = worktree.worktree.path; - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("bootstrap-thread-meta-update"), - threadId: command.threadId, - branch: worktree.worktree.branch, - worktreePath: targetWorktreePath, - }); - } + worktreePath: input.worktreePath, + scriptId: input.scriptId, + terminalId: input.terminalId, + detail: + error instanceof Error + ? error.message + : "Unknown setup activity dispatch failure.", + }, + ), + ), + ); + }; - yield* runSetupProgram(); + const runSetupProgram = () => + bootstrap?.runSetupScript && targetWorktreePath + ? (() => { + const worktreePath = targetWorktreePath; + const requestedAt = new Date().toISOString(); + return projectSetupScriptRunner + .runForThread({ + threadId: command.threadId, + ...(targetProjectId ? { projectId: targetProjectId } : {}), + ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), + worktreePath, + }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + recordSetupScriptLaunchFailure({ + error, + requestedAt, + worktreePath, + }), + onSuccess: (setupResult) => { + if (setupResult.status !== "started") { + return Effect.void; + } + return recordSetupScriptStarted({ + requestedAt, + worktreePath, + scriptId: setupResult.scriptId, + scriptName: setupResult.scriptName, + terminalId: setupResult.terminalId, + }); + }, + }), + ); + })() + : Effect.void; - return yield* orchestrationEngine.dispatch(finalTurnStartCommand); - }); + const bootstrapProgram = Effect.gen(function* () { + if (bootstrap?.createThread) { + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch: bootstrap.createThread.branch, + worktreePath: bootstrap.createThread.worktreePath, + createdAt: bootstrap.createThread.createdAt, + }); + createdThread = true; + } - return yield* bootstrapProgram.pipe( - Effect.catchCause((cause) => { - const dispatchError = toBootstrapDispatchCommandCauseError(cause); - if (Cause.hasInterruptsOnly(cause)) { - return Effect.fail(dispatchError); + if (bootstrap?.prepareWorktree) { + const worktree = yield* git.createWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + branch: bootstrap.prepareWorktree.baseBranch, + newBranch: bootstrap.prepareWorktree.branch, + path: null, + }); + targetWorktreePath = worktree.worktree.path; + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("bootstrap-thread-meta-update"), + threadId: command.threadId, + branch: worktree.worktree.branch, + worktreePath: targetWorktreePath, + }); } - return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); - }), - ); - }); - const dispatchNormalizedCommand = ( - normalizedCommand: OrchestrationCommand, - ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { - const dispatchEffect = - normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap - ? dispatchBootstrapTurnStart(normalizedCommand) - : orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), - ), - ); + yield* runSetupProgram(); - return startup - .enqueueCommand(dispatchEffect) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), - ), - ); - }; + return yield* orchestrationEngine.dispatch(finalTurnStartCommand); + }); - const loadServerConfig = Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = yield* providerRegistry.getProviders; - const settings = yield* serverSettings.getSettings; - const environment = yield* serverEnvironment.getDescriptor; - const auth = yield* serverAuth.getDescriptor(); + return yield* bootstrapProgram.pipe( + Effect.catchCause((cause) => { + const dispatchError = toBootstrapDispatchCommandCauseError(cause); + if (Cause.hasInterruptsOnly(cause)) { + return Effect.fail(dispatchError); + } + return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); + }), + ); + }); - return { - environment, - auth, - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - availableEditors: resolveAvailableEditors(), - observability: { - logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined ? { otlpMetricsUrl: config.otlpMetricsUrl } : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, - }, - settings, - }; - }); - - const refreshGitStatus = (cwd: string) => - gitStatusBroadcaster - .refreshStatus(cwd) - .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); - - return WsRpcGroup.of({ - [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getSnapshot, - projectionSnapshotQuery.getSnapshot().pipe( - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration snapshot", - cause, - }), - ), - ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.dispatchCommand, - Effect.gen(function* () { - const normalizedCommand = yield* normalizeDispatchCommand(command); - const result = yield* dispatchNormalizedCommand(normalizedCommand); - if (normalizedCommand.type === "thread.archive") { - yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: normalizedCommand.threadId, - error: error.message, - }), - ), - ); - } - return result; - }).pipe( + const dispatchNormalizedCommand = ( + normalizedCommand: OrchestrationCommand, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { + const dispatchEffect = + normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap + ? dispatchBootstrapTurnStart(normalizedCommand) + : orchestrationEngine + .dispatch(normalizedCommand) + .pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); + + return startup + .enqueueCommand(dispatchEffect) + .pipe( Effect.mapError((cause) => - Schema.is(OrchestrationDispatchCommandError)(cause) - ? cause - : new OrchestrationDispatchCommandError({ - message: "Failed to dispatch orchestration command", + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); + }; + + const loadServerConfig = Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.loadConfigState; + const providers = yield* providerRegistry.getProviders; + const settings = yield* serverSettings.getSettings; + const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); + + return { + environment, + auth, + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors: resolveAvailableEditors(), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined + ? { otlpMetricsUrl: config.otlpMetricsUrl } + : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + }, + settings, + }; + }); + + const refreshGitStatus = (cwd: string) => + gitStatusBroadcaster + .refreshStatus(cwd) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + + return WsRpcGroup.of({ + [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getSnapshot, + projectionSnapshotQuery.getSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration snapshot", cause, }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getTurnDiff, - checkpointDiffQuery.getTurnDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetTurnDiffError({ - message: "Failed to load turn diff", - cause, - }), + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.dispatchCommand, + Effect.gen(function* () { + const normalizedCommand = yield* normalizeDispatchCommand(command); + const result = yield* dispatchNormalizedCommand(normalizedCommand); + if (normalizedCommand.type === "thread.archive") { + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: normalizedCommand.threadId, + error: error.message, + }), + ), + ); + } + return result; + }).pipe( + Effect.mapError((cause) => + Schema.is(OrchestrationDispatchCommandError)(cause) + ? cause + : new OrchestrationDispatchCommandError({ + message: "Failed to dispatch orchestration command", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getFullThreadDiff, - checkpointDiffQuery.getFullThreadDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetFullThreadDiffError({ - message: "Failed to load full thread diff", - cause, - }), + [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getTurnDiff, + checkpointDiffQuery.getTurnDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetTurnDiffError({ + message: "Failed to load turn diff", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.replayEvents, - Stream.runCollect( - orchestrationEngine.readEvents( - clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), - ), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.flatMap(enrichOrchestrationEvents), - Effect.mapError( - (cause) => - new OrchestrationReplayEventsError({ - message: "Failed to replay orchestration events", - cause, - }), + [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getFullThreadDiff, + checkpointDiffQuery.getFullThreadDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetFullThreadDiffError({ + message: "Failed to load full thread diff", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeOrchestrationDomainEvents, - Effect.gen(function* () { - const snapshot = yield* orchestrationEngine.getReadModel(); - const fromSequenceExclusive = snapshot.snapshotSequence; - const replayEvents: Array = yield* Stream.runCollect( - orchestrationEngine.readEvents(fromSequenceExclusive), + [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.replayEvents, + Stream.runCollect( + orchestrationEngine.readEvents( + clamp(input.fromSequenceExclusive, { + maximum: Number.MAX_SAFE_INTEGER, + minimum: 0, + }), + ), ).pipe( Effect.map((events) => Array.from(events)), Effect.flatMap(enrichOrchestrationEvents), - Effect.catch(() => Effect.succeed([] as Array)), - ); - const replayStream = Stream.fromIterable(replayEvents); - const liveStream = orchestrationEngine.streamDomainEvents.pipe( - Stream.mapEffect(enrichProjectEvent), - ); - const source = Stream.merge(replayStream, liveStream); - type SequenceState = { - readonly nextSequence: number; - readonly pendingBySequence: Map; - }; - const state = yield* Ref.make({ - nextSequence: fromSequenceExclusive + 1, - pendingBySequence: new Map(), - }); + Effect.mapError( + (cause) => + new OrchestrationReplayEventsError({ + message: "Failed to replay orchestration events", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), + [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeOrchestrationDomainEvents, + Effect.gen(function* () { + const snapshot = yield* orchestrationEngine.getReadModel(); + const fromSequenceExclusive = snapshot.snapshotSequence; + const replayEvents: Array = yield* Stream.runCollect( + orchestrationEngine.readEvents(fromSequenceExclusive), + ).pipe( + Effect.map((events) => Array.from(events)), + Effect.flatMap(enrichOrchestrationEvents), + Effect.catch(() => Effect.succeed([] as Array)), + ); + const replayStream = Stream.fromIterable(replayEvents); + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.mapEffect(enrichProjectEvent), + ); + const source = Stream.merge(replayStream, liveStream); + type SequenceState = { + readonly nextSequence: number; + readonly pendingBySequence: Map; + }; + const state = yield* Ref.make({ + nextSequence: fromSequenceExclusive + 1, + pendingBySequence: new Map(), + }); - return source.pipe( - Stream.mapEffect((event) => - Ref.modify( - state, - ({ - nextSequence, - pendingBySequence, - }): [Array, SequenceState] => { - if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { - return [[], { nextSequence, pendingBySequence }]; - } - - const updatedPending = new Map(pendingBySequence); - updatedPending.set(event.sequence, event); - - const emit: Array = []; - let expected = nextSequence; - for (;;) { - const expectedEvent = updatedPending.get(expected); - if (!expectedEvent) { - break; + return source.pipe( + Stream.mapEffect((event) => + Ref.modify( + state, + ({ + nextSequence, + pendingBySequence, + }): [Array, SequenceState] => { + if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { + return [[], { nextSequence, pendingBySequence }]; } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } - return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; - }, + const updatedPending = new Map(pendingBySequence); + updatedPending.set(event.sequence, event); + + const emit: Array = []; + let expected = nextSequence; + for (;;) { + const expectedEvent = updatedPending.get(expected); + if (!expectedEvent) { + break; + } + emit.push(expectedEvent); + updatedPending.delete(expected); + expected += 1; + } + + return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; + }, + ), ), - ), - Stream.flatMap((events) => Stream.fromIterable(events)), - ); + Stream.flatMap((events) => Stream.fromIterable(events)), + ); + }), + { "rpc.aggregate": "orchestration" }, + ), + [WS_METHODS.serverGetConfig]: (_input) => + observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { + "rpc.aggregate": "server", + }), + [WS_METHODS.serverRefreshProviders]: (_input) => + observeRpcEffect( + WS_METHODS.serverRefreshProviders, + providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverUpsertKeybinding]: (rule) => + observeRpcEffect( + WS_METHODS.serverUpsertKeybinding, + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverGetSettings]: (_input) => + observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { + "rpc.aggregate": "server", }), - { "rpc.aggregate": "orchestration" }, - ), - [WS_METHODS.serverGetConfig]: (_input) => - observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverRefreshProviders]: (_input) => - observeRpcEffect( - WS_METHODS.serverRefreshProviders, - providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.serverUpsertKeybinding]: (rule) => - observeRpcEffect( - WS_METHODS.serverUpsertKeybinding, - Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; + [WS_METHODS.serverUpdateSettings]: ({ patch }) => + observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { + "rpc.aggregate": "server", }), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.serverGetSettings]: (_input) => - observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverUpdateSettings]: ({ patch }) => - observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { - "rpc.aggregate": "server", - }), - [WS_METHODS.projectsSearchEntries]: (input) => - observeRpcEffect( - WS_METHODS.projectsSearchEntries, - workspaceEntries.search(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchEntriesError({ - message: `Failed to search workspace entries: ${cause.detail}`, + [WS_METHODS.projectsSearchEntries]: (input) => + observeRpcEffect( + WS_METHODS.projectsSearchEntries, + workspaceEntries.search(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchEntriesError({ + message: `Failed to search workspace entries: ${cause.detail}`, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.projectsWriteFile]: (input) => + observeRpcEffect( + WS_METHODS.projectsWriteFile, + workspaceFileSystem.writeFile(input).pipe( + Effect.mapError((cause) => { + const message = Schema.is(WorkspacePathOutsideRootError)(cause) + ? "Workspace file path must stay within the project root." + : "Failed to write workspace file"; + return new ProjectWriteFileError({ + message, cause, - }), + }); + }), ), + { "rpc.aggregate": "workspace" }, ), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.projectsWriteFile]: (input) => - observeRpcEffect( - WS_METHODS.projectsWriteFile, - workspaceFileSystem.writeFile(input).pipe( - Effect.mapError((cause) => { - const message = Schema.is(WorkspacePathOutsideRootError)(cause) - ? "Workspace file path must stay within the project root." - : "Failed to write workspace file"; - return new ProjectWriteFileError({ - message, - cause, - }); - }), + [WS_METHODS.shellOpenInEditor]: (input) => + observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.subscribeGitStatus]: (input) => + observeRpcStream( + WS_METHODS.subscribeGitStatus, + gitStatusBroadcaster.streamStatus(input), + { + "rpc.aggregate": "git", + }, ), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.shellOpenInEditor]: (input) => - observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { - "rpc.aggregate": "workspace", - }), - [WS_METHODS.subscribeGitStatus]: (input) => - observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitRefreshStatus]: (input) => - observeRpcEffect( - WS_METHODS.gitRefreshStatus, - gitStatusBroadcaster.refreshStatus(input.cwd), - { - "rpc.aggregate": "git", - }, - ), - [WS_METHODS.gitPull]: (input) => - observeRpcEffect( - WS_METHODS.gitPull, - git.pullCurrentBranch(input.cwd).pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Effect.failCause(cause), - onSuccess: (result) => - refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), - }), + [WS_METHODS.gitRefreshStatus]: (input) => + observeRpcEffect( + WS_METHODS.gitRefreshStatus, + gitStatusBroadcaster.refreshStatus(input.cwd), + { + "rpc.aggregate": "git", + }, ), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitRunStackedAction]: (input) => - observeRpcStream( - WS_METHODS.gitRunStackedAction, - Stream.callback((queue) => + [WS_METHODS.gitPull]: (input) => + observeRpcEffect( + WS_METHODS.gitPull, + git.pullCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), + ), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitRunStackedAction]: (input) => + observeRpcStream( + WS_METHODS.gitRunStackedAction, + Stream.callback((queue) => + gitManager + .runStackedAction(input, { + actionId: input.actionId, + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + }, + }) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: () => + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), + }), + ), + ), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitResolvePullRequest]: (input) => + observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { + "rpc.aggregate": "git", + }), + [WS_METHODS.gitPreparePullRequestThread]: (input) => + observeRpcEffect( + WS_METHODS.gitPreparePullRequestThread, gitManager - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => - refreshGitStatus(input.cwd).pipe( - Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), - ), - }), - ), + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitListBranches]: (input) => + observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { + "rpc.aggregate": "git", + }), + [WS_METHODS.gitCreateWorktree]: (input) => + observeRpcEffect( + WS_METHODS.gitCreateWorktree, + git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, ), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitResolvePullRequest]: (input) => - observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitPreparePullRequestThread]: (input) => - observeRpcEffect( - WS_METHODS.gitPreparePullRequestThread, - gitManager - .preparePullRequestThread(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitListBranches]: (input) => - observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitCreateWorktree]: (input) => - observeRpcEffect( - WS_METHODS.gitCreateWorktree, - git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitRemoveWorktree]: (input) => - observeRpcEffect( - WS_METHODS.gitRemoveWorktree, - git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitCreateBranch]: (input) => - observeRpcEffect( - WS_METHODS.gitCreateBranch, - git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitCheckout]: (input) => - observeRpcEffect( - WS_METHODS.gitCheckout, - Effect.scoped(git.checkoutBranch(input)).pipe( - Effect.tap(() => refreshGitStatus(input.cwd)), + [WS_METHODS.gitRemoveWorktree]: (input) => + observeRpcEffect( + WS_METHODS.gitRemoveWorktree, + git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, ), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitInit]: (input) => - observeRpcEffect( - WS_METHODS.gitInit, - git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.terminalOpen]: (input) => - observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalWrite]: (input) => - observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalResize]: (input) => - observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClear]: (input) => - observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalRestart]: (input) => - observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClose]: (input) => - observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.subscribeTerminalEvents]: (_input) => - observeRpcStream( - WS_METHODS.subscribeTerminalEvents, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribe((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), + [WS_METHODS.gitCreateBranch]: (input) => + observeRpcEffect( + WS_METHODS.gitCreateBranch, + git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitCheckout]: (input) => + observeRpcEffect( + WS_METHODS.gitCheckout, + Effect.scoped(git.checkoutBranch(input)).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), ), + { "rpc.aggregate": "git" }, ), - { "rpc.aggregate": "terminal" }, - ), - [WS_METHODS.subscribeServerConfig]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeServerConfig, - Effect.gen(function* () { - const keybindingsUpdates = keybindings.streamChanges.pipe( - Stream.map((event) => ({ - version: 1 as const, - type: "keybindingsUpdated" as const, - payload: { - issues: event.issues, - }, - })), - ); - const providerStatuses = providerRegistry.streamChanges.pipe( - Stream.map((providers) => ({ - version: 1 as const, - type: "providerStatuses" as const, - payload: { providers }, - })), - ); - const settingsUpdates = serverSettings.streamChanges.pipe( - Stream.map((settings) => ({ - version: 1 as const, - type: "settingsUpdated" as const, - payload: { settings }, - })), - ); - - return Stream.concat( - Stream.make({ - version: 1 as const, - type: "snapshot" as const, - config: yield* loadServerConfig, - }), - Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), - ); + [WS_METHODS.gitInit]: (input) => + observeRpcEffect( + WS_METHODS.gitInit, + git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.terminalOpen]: (input) => + observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { + "rpc.aggregate": "terminal", }), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.subscribeServerLifecycle]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeServerLifecycle, - Effect.gen(function* () { - const snapshot = yield* lifecycleEvents.snapshot; - const snapshotEvents = Array.from(snapshot.events).toSorted( - (left, right) => left.sequence - right.sequence, - ); - const liveEvents = lifecycleEvents.stream.pipe( - Stream.filter((event) => event.sequence > snapshot.sequence), - ); - return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); + [WS_METHODS.terminalWrite]: (input) => + observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { + "rpc.aggregate": "terminal", }), - { "rpc.aggregate": "server" }, - ), - }); - }), -); + [WS_METHODS.terminalResize]: (input) => + observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalClear]: (input) => + observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalRestart]: (input) => + observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalClose]: (input) => + observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.subscribeTerminalEvents]: (_input) => + observeRpcStream( + WS_METHODS.subscribeTerminalEvents, + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.subscribe((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + { "rpc.aggregate": "terminal" }, + ), + [WS_METHODS.subscribeServerConfig]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeServerConfig, + Effect.gen(function* () { + const keybindingsUpdates = keybindings.streamChanges.pipe( + Stream.map((event) => ({ + version: 1 as const, + type: "keybindingsUpdated" as const, + payload: { + issues: event.issues, + }, + })), + ); + const providerStatuses = providerRegistry.streamChanges.pipe( + Stream.map((providers) => ({ + version: 1 as const, + type: "providerStatuses" as const, + payload: { providers }, + })), + ); + const settingsUpdates = serverSettings.streamChanges.pipe( + Stream.map((settings) => ({ + version: 1 as const, + type: "settingsUpdated" as const, + payload: { settings }, + })), + ); + + return Stream.concat( + Stream.make({ + version: 1 as const, + type: "snapshot" as const, + config: yield* loadServerConfig, + }), + Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), + ); + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.subscribeServerLifecycle]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeServerLifecycle, + Effect.gen(function* () { + const snapshot = yield* lifecycleEvents.snapshot; + const snapshotEvents = Array.from(snapshot.events).toSorted( + (left, right) => left.sequence - right.sequence, + ); + const liveEvents = lifecycleEvents.stream.pipe( + Stream.filter((event) => event.sequence > snapshot.sequence), + ); + return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.subscribeAuthAccess]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeAuthAccess, + Effect.gen(function* () { + const initialSnapshot = yield* loadAuthAccessSnapshot(); + const revisionRef = yield* Ref.make(1); + const accessChanges: Stream.Stream< + BootstrapCredentialChange | SessionCredentialChange + > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges); + + const liveEvents: Stream.Stream = accessChanges.pipe( + Stream.mapEffect((change) => + Ref.updateAndGet(revisionRef, (revision) => revision + 1).pipe( + Effect.map((revision) => + toAuthAccessStreamEvent(change, revision, currentSessionId), + ), + ), + ), + ); + + return Stream.concat( + Stream.make({ + version: 1 as const, + revision: 1, + type: "snapshot" as const, + payload: initialSnapshot, + }), + liveEvents, + ); + }), + { "rpc.aggregate": "auth" }, + ), + }); + }), + ); export const websocketRpcRouteLayer = Layer.unwrap( - Effect.gen(function* () { - const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { - spanPrefix: "ws.rpc", - spanAttributes: { - "rpc.transport": "websocket", - "rpc.system": "effect-rpc", - }, - }).pipe(Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson))); - return HttpRouter.add( + Effect.succeed( + HttpRouter.add( "GET", "/ws", Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* ServerAuth; - yield* serverAuth.authenticateWebSocketUpgrade(request); - return yield* rpcWebSocketHttpEffect; - }).pipe( - Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error))), - ), - ); - }), + const sessions = yield* SessionCredentialService; + const session = yield* serverAuth.authenticateWebSocketUpgrade(request); + const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { + spanPrefix: "ws.rpc", + spanAttributes: { + "rpc.transport": "websocket", + "rpc.system": "effect-rpc", + }, + }).pipe( + Effect.provide( + makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)), + ), + ); + return yield* Effect.acquireUseRelease( + sessions.markConnected(session.sessionId), + () => rpcWebSocketHttpEffect, + () => sessions.markDisconnected(session.sessionId), + ); + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), + ), + ), ); diff --git a/apps/web/package.json b/apps/web/package.json index d127743705..adc1d15d2a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 8e87d20190..c56c0f9484 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -98,9 +98,10 @@ describe("resolveInitialServerAuthGateState", () => { await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost/api/auth/session"); expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost/api/auth/bootstrap"); + expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost/api/auth/session"); }); it("uses https fetch urls when the primary environment uses wss", async () => { @@ -296,6 +297,76 @@ describe("resolveInitialServerAuthGateState", () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + wsUrl: "ws://localhost:3773/ws", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./authBootstrap"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(100); + + await expect(gateStatePromise).resolves.toEqual({ + status: "authenticated", + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost/api/auth/session"); + expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost/api/auth/session"); + }); + it("revalidates the server session state after a previous authenticated result", async () => { const fetchMock = vi .fn() @@ -345,6 +416,7 @@ describe("resolveInitialServerAuthGateState", () => { it("creates a pairing credential from the authenticated auth endpoint", async () => { const fetchMock = vi.fn().mockResolvedValueOnce( jsonResponse({ + id: "pairing-link-1", credential: "pairing-token", expiresAt: "2026-04-05T00:00:00.000Z", }), @@ -354,6 +426,7 @@ describe("resolveInitialServerAuthGateState", () => { const { createServerPairingCredential } = await import("./authBootstrap"); await expect(createServerPairingCredential()).resolves.toEqual({ + id: "pairing-link-1", credential: "pairing-token", expiresAt: "2026-04-05T00:00:00.000Z", }); diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts index 65402bbb73..2ba1e129a8 100644 --- a/apps/web/src/authBootstrap.ts +++ b/apps/web/src/authBootstrap.ts @@ -1,11 +1,34 @@ import type { AuthBootstrapInput, AuthBootstrapResult, + AuthSessionId, AuthPairingCredentialResult, + AuthRevokeClientSessionInput, + AuthRevokePairingLinkInput, AuthSessionState, } from "@t3tools/contracts"; import { resolveServerHttpUrl } from "./lib/utils"; +export interface ServerPairingLinkRecord { + readonly id: string; + readonly credential: string; + readonly role: "owner" | "client"; + readonly subject: string; + readonly createdAt: string; + readonly expiresAt: string; +} + +export interface ServerClientSessionRecord { + readonly sessionId: AuthSessionId; + readonly subject: string; + readonly role: "owner" | "client"; + readonly method: "browser-session-cookie" | "bearer-session-token"; + readonly issuedAt: string; + readonly expiresAt: string; + readonly connected: boolean; + readonly current: boolean; +} + export type ServerAuthGateState = | { status: "authenticated" } | { @@ -18,6 +41,8 @@ let bootstrapPromise: Promise | null = null; const TRANSIENT_AUTH_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); const AUTH_BOOTSTRAP_RETRY_TIMEOUT_MS = 15_000; const AUTH_BOOTSTRAP_RETRY_STEP_MS = 500; +const AUTH_SESSION_ESTABLISH_TIMEOUT_MS = 2_000; +const AUTH_SESSION_ESTABLISH_STEP_MS = 100; class AuthBootstrapHttpError extends Error { readonly status: number; @@ -79,6 +104,11 @@ async function fetchSessionState(): Promise { }); } +async function readErrorMessage(response: Response, fallbackMessage: string): Promise { + const text = await response.text(); + return text || fallbackMessage; +} + async function exchangeBootstrapCredential(credential: string): Promise { return retryTransientAuthBootstrap(async () => { const payload: AuthBootstrapInput = { credential }; @@ -140,6 +170,23 @@ function waitForAuthBootstrapRetry(delayMs: number): Promise { }); } +async function waitForAuthenticatedSessionAfterBootstrap(): Promise { + const startedAt = Date.now(); + + while (true) { + const session = await fetchSessionState(); + if (session.authenticated) { + return session; + } + + if (Date.now() - startedAt >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { + throw new Error("Timed out waiting for authenticated session after bootstrap."); + } + + await waitForAuthBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS); + } +} + async function bootstrapServerAuth(): Promise { const bootstrapCredential = getBootstrapCredential(); const currentSession = await fetchSessionState(); @@ -156,6 +203,7 @@ async function bootstrapServerAuth(): Promise { try { await exchangeBootstrapCredential(bootstrapCredential); + await waitForAuthenticatedSessionAfterBootstrap(); return { status: "authenticated" }; } catch (error) { return { @@ -184,13 +232,102 @@ export async function createServerPairingCredential(): Promise> { + const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/pairing-links" }), { + credentials: "include", + }); + + if (!response.ok) { + throw new Error( + await readErrorMessage(response, `Failed to load pairing links (${response.status}).`), + ); + } + + return (await response.json()) as ReadonlyArray; +} + +export async function revokeServerPairingLink(id: string): Promise { + const payload: AuthRevokePairingLinkInput = { id }; + const response = await fetch( + resolveServerHttpUrl({ pathname: "/api/auth/pairing-links/revoke" }), + { + body: JSON.stringify(payload), + credentials: "include", + headers: { + "content-type": "application/json", + }, + method: "POST", + }, + ); + + if (!response.ok) { + throw new Error( + await readErrorMessage(response, `Failed to revoke pairing link (${response.status}).`), + ); + } +} + +export async function listServerClientSessions(): Promise< + ReadonlyArray +> { + const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/clients" }), { + credentials: "include", + }); + + if (!response.ok) { + throw new Error( + await readErrorMessage(response, `Failed to load paired clients (${response.status}).`), + ); + } + + return (await response.json()) as ReadonlyArray; +} + +export async function revokeServerClientSession(sessionId: AuthSessionId): Promise { + const payload: AuthRevokeClientSessionInput = { sessionId }; + const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/clients/revoke" }), { + body: JSON.stringify(payload), + credentials: "include", + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + + if (!response.ok) { + throw new Error( + await readErrorMessage(response, `Failed to revoke client access (${response.status}).`), + ); + } +} + +export async function revokeOtherServerClientSessions(): Promise { + const response = await fetch( + resolveServerHttpUrl({ pathname: "/api/auth/clients/revoke-others" }), + { + credentials: "include", + method: "POST", + }, + ); + + if (!response.ok) { + throw new Error( + await readErrorMessage(response, `Failed to revoke other clients (${response.status}).`), + ); + } + + const body = (await response.json()) as { readonly revokedCount: number }; + return body.revokedCount; +} + export function resolveInitialServerAuthGateState(): Promise { if (bootstrapPromise) { return bootstrapPromise; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx new file mode 100644 index 0000000000..f1585588c4 --- /dev/null +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -0,0 +1,782 @@ +import { InfoIcon, QrCodeIcon } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + type AuthClientSession, + type AuthPairingLink, + type DesktopServerExposureState, +} from "@t3tools/contracts"; +import { DateTime } from "effect"; + +import { + createServerPairingCredential, + revokeOtherServerClientSessions, + revokeServerClientSession, + revokeServerPairingLink, + type ServerClientSessionRecord, + type ServerPairingLinkRecord, +} from "../../authBootstrap"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { cn } from "../../lib/utils"; +import { formatExpiresInLabel } from "../../timestampFormat"; +import { getPrimaryWsRpcClientEntry } from "../../wsRpcClient"; +import { + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Spinner } from "../ui/spinner"; +import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +function formatAccessTimestamp(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return accessTimestampFormatter.format(parsed); +} + +/** Top rule + `mt-3` from the row header: pairing uses this with a bare `
    `; clients adds a summary line first. */ +const CONNECTIONS_ACCESS_LIST_OUTER_CLASSNAME = "mt-3 border-t border-border"; + +const CONNECTIONS_ACCESS_LIST_UL_CLASSNAME = + "list-none divide-y divide-border pb-4 [&>li]:py-2 [&>li:last-child]:pb-0"; + +const CONNECTIONS_ROW_PRIMARY_LINE_CLASSNAME = + "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-xs leading-none"; + +/** Same primary → secondary gap for pairing and client rows (`

    ` has no extra `mt-*`). */ +const CONNECTIONS_ROW_TEXT_STACK_CLASSNAME = "flex min-w-0 flex-1 flex-col gap-1 text-left"; + +function sortDesktopPairingLinks(links: ReadonlyArray) { + return [...links].toSorted( + (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), + ); +} + +function sortDesktopClientSessions(sessions: ReadonlyArray) { + return [...sessions].toSorted((left, right) => { + if (left.current !== right.current) { + return left.current ? -1 : 1; + } + if (left.connected !== right.connected) { + return left.connected ? -1 : 1; + } + return new Date(right.issuedAt).getTime() - new Date(left.issuedAt).getTime(); + }); +} + +function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord { + return { + ...pairingLink, + createdAt: DateTime.formatIso(pairingLink.createdAt), + expiresAt: DateTime.formatIso(pairingLink.expiresAt), + }; +} + +function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord { + return { + ...clientSession, + issuedAt: DateTime.formatIso(clientSession.issuedAt), + expiresAt: DateTime.formatIso(clientSession.expiresAt), + }; +} + +function upsertDesktopPairingLink( + current: ReadonlyArray, + next: ServerPairingLinkRecord, +) { + const existingIndex = current.findIndex((pairingLink) => pairingLink.id === next.id); + if (existingIndex === -1) { + return sortDesktopPairingLinks([...current, next]); + } + const updated = [...current]; + updated[existingIndex] = next; + return sortDesktopPairingLinks(updated); +} + +function removeDesktopPairingLink(current: ReadonlyArray, id: string) { + return current.filter((pairingLink) => pairingLink.id !== id); +} + +function upsertDesktopClientSession( + current: ReadonlyArray, + next: ServerClientSessionRecord, +) { + const existingIndex = current.findIndex( + (clientSession) => clientSession.sessionId === next.sessionId, + ); + if (existingIndex === -1) { + return sortDesktopClientSessions([...current, next]); + } + const updated = [...current]; + updated[existingIndex] = next; + return sortDesktopClientSessions(updated); +} + +function removeDesktopClientSession( + current: ReadonlyArray, + sessionId: ServerClientSessionRecord["sessionId"], +) { + return current.filter((clientSession) => clientSession.sessionId !== sessionId); +} + +function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { + const url = new URL(endpointUrl); + url.pathname = "/pair"; + url.searchParams.set("token", credential); + return url.toString(); +} + +type PairingLinkListRowProps = { + pairingLink: ServerPairingLinkRecord; + /** Wall clock for expiry countdown; must update ~1Hz (parent drives via `useRelativeTimeTick`) so React Compiler keeps the row reactive. */ + nowMs: number; + endpointUrl: string | null | undefined; + revokingPairingLinkId: string | null; + onRevoke: (id: string) => void; +}; + +function PairingLinkListRow({ + pairingLink, + nowMs, + endpointUrl, + revokingPairingLinkId, + onRevoke, +}: PairingLinkListRowProps) { + const pairingUrl = + endpointUrl != null && endpointUrl !== "" + ? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential) + : `/pair?token=${pairingLink.credential}`; + + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: () => { + toastManager.add({ + type: "success", + title: "Pairing URL copied", + description: "Open it in the client you want to pair to this environment.", + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Could not copy pairing URL", + description: error.message, + }); + }, + }); + + const handleCopy = useCallback(() => { + copyToClipboard(pairingUrl, undefined); + }, [copyToClipboard, pairingUrl]); + + const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); + + return ( +

  • +
    +
    + + + {pairingLink.role === "owner" ? "Owner" : "Client"} + + + · + + + {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} + +
    +

    + {pairingUrl} +

    +
    +
    + + + } + > + + + + + + + + +
    +
  • + ); +} + +type ConnectedClientListRowProps = { + clientSession: ServerClientSessionRecord; + revokingClientSessionId: string | null; + onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void; +}; + +function ConnectedClientListRow({ + clientSession, + revokingClientSessionId, + onRevokeSession, +}: ConnectedClientListRowProps) { + const stateLabel = clientSession.current + ? "This client" + : clientSession.connected + ? "Connected" + : "Offline"; + const isLive = clientSession.current || clientSession.connected; + const roleLabel = clientSession.role === "owner" ? "Owner" : "Client"; + + return ( +
  • +
    +
    + + + {clientSession.subject} + + + } + > + + + +

    + Issued {formatAccessTimestamp(clientSession.issuedAt)} +

    +

    + Expires {formatAccessTimestamp(clientSession.expiresAt)} +

    +
    +
    +
    + + · + + {stateLabel} +
    +

    {roleLabel}

    +
    +
    + +
    +
  • + ); +} + +export function ConnectionsSettings() { + const desktopBridge = window.desktopBridge; + + const [desktopServerExposureState, setDesktopServerExposureState] = + useState(null); + const [desktopServerExposureError, setDesktopServerExposureError] = useState(null); + const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); + const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState< + DesktopServerExposureState["mode"] | null + >(null); + const [isCreatingDesktopPairingUrl, setIsCreatingDesktopPairingUrl] = useState(false); + const [desktopPairingLinks, setDesktopPairingLinks] = useState< + ReadonlyArray + >([]); + const [desktopClientSessions, setDesktopClientSessions] = useState< + ReadonlyArray + >([]); + const [desktopAccessManagementError, setDesktopAccessManagementError] = useState( + null, + ); + const [isLoadingDesktopAccessManagement, setIsLoadingDesktopAccessManagement] = useState(false); + const [revokingDesktopPairingLinkId, setRevokingDesktopPairingLinkId] = useState( + null, + ); + const [revokingDesktopClientSessionId, setRevokingDesktopClientSessionId] = useState< + string | null + >(null); + const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); + + const createDesktopPairingUrl = useCallback(async () => { + setIsCreatingDesktopPairingUrl(true); + setDesktopAccessManagementError(null); + try { + await createServerPairingCredential(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create pairing URL."; + setDesktopAccessManagementError(message); + toastManager.add({ + type: "error", + title: "Could not create pairing URL", + description: message, + }); + } finally { + setIsCreatingDesktopPairingUrl(false); + } + }, []); + + const handleDesktopServerExposureChange = useCallback( + async (checked: boolean) => { + if (!desktopBridge) return; + + setIsUpdatingDesktopServerExposure(true); + setDesktopServerExposureError(null); + try { + const nextState = await desktopBridge.setServerExposureMode( + checked ? "network-accessible" : "local-only", + ); + setDesktopServerExposureState(nextState); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to update network exposure."; + setPendingDesktopServerExposureMode(null); + setDesktopServerExposureError(message); + toastManager.add({ + type: "error", + title: "Could not update network access", + description: message, + }); + setIsUpdatingDesktopServerExposure(false); + } + }, + [desktopBridge], + ); + + const handleConfirmDesktopServerExposureChange = useCallback(() => { + if (pendingDesktopServerExposureMode === null) return; + const checked = pendingDesktopServerExposureMode === "network-accessible"; + void handleDesktopServerExposureChange(checked); + }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]); + + const handleCreateDesktopPairingUrl = useCallback(() => { + if (!desktopServerExposureState?.endpointUrl) return; + void createDesktopPairingUrl(); + }, [createDesktopPairingUrl, desktopServerExposureState?.endpointUrl]); + + const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { + setRevokingDesktopPairingLinkId(id); + setDesktopAccessManagementError(null); + try { + await revokeServerPairingLink(id); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to revoke pairing link."; + setDesktopAccessManagementError(message); + toastManager.add({ + type: "error", + title: "Could not revoke pairing link", + description: message, + }); + } finally { + setRevokingDesktopPairingLinkId(null); + } + }, []); + + const handleRevokeDesktopClientSession = useCallback( + async (sessionId: ServerClientSessionRecord["sessionId"]) => { + setRevokingDesktopClientSessionId(sessionId); + setDesktopAccessManagementError(null); + try { + await revokeServerClientSession(sessionId); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to revoke client access."; + setDesktopAccessManagementError(message); + toastManager.add({ + type: "error", + title: "Could not revoke client access", + description: message, + }); + } finally { + setRevokingDesktopClientSessionId(null); + } + }, + [], + ); + + const handleRevokeOtherDesktopClients = useCallback(async () => { + setIsRevokingOtherDesktopClients(true); + setDesktopAccessManagementError(null); + try { + const revokedCount = await revokeOtherServerClientSessions(); + toastManager.add({ + type: "success", + title: revokedCount === 1 ? "Revoked 1 other client" : `Revoked ${revokedCount} clients`, + description: "Other paired clients will need a new pairing link before reconnecting.", + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to revoke other clients."; + setDesktopAccessManagementError(message); + toastManager.add({ + type: "error", + title: "Could not revoke other clients", + description: message, + }); + } finally { + setIsRevokingOtherDesktopClients(false); + } + }, []); + + useEffect(() => { + if (!desktopBridge) return; + + let cancelled = false; + setIsLoadingDesktopAccessManagement(true); + const unsubscribeAuthAccess = getPrimaryWsRpcClientEntry().client.server.subscribeAuthAccess( + (event) => { + if (cancelled) { + return; + } + + switch (event.type) { + case "snapshot": + setDesktopPairingLinks( + sortDesktopPairingLinks( + event.payload.pairingLinks.map((pairingLink) => + toDesktopPairingLinkRecord(pairingLink), + ), + ), + ); + setDesktopClientSessions( + sortDesktopClientSessions( + event.payload.clientSessions.map((clientSession) => + toDesktopClientSessionRecord(clientSession), + ), + ), + ); + break; + case "pairingLinkUpserted": + setDesktopPairingLinks((current) => + upsertDesktopPairingLink(current, toDesktopPairingLinkRecord(event.payload)), + ); + break; + case "pairingLinkRemoved": + setDesktopPairingLinks((current) => + removeDesktopPairingLink(current, event.payload.id), + ); + break; + case "clientUpserted": + setDesktopClientSessions((current) => + upsertDesktopClientSession(current, toDesktopClientSessionRecord(event.payload)), + ); + break; + case "clientRemoved": + setDesktopClientSessions((current) => + removeDesktopClientSession(current, event.payload.sessionId), + ); + break; + } + + setDesktopAccessManagementError(null); + setIsLoadingDesktopAccessManagement(false); + }, + { + onResubscribe: () => { + if (!cancelled) { + setIsLoadingDesktopAccessManagement(true); + } + }, + }, + ); + void desktopBridge + .getServerExposureState() + .then((state) => { + if (cancelled) return; + setDesktopServerExposureState(state); + }) + .catch((error: unknown) => { + if (cancelled) return; + const message = + error instanceof Error ? error.message : "Failed to load network exposure state."; + setDesktopServerExposureError(message); + }); + + return () => { + cancelled = true; + unsubscribeAuthAccess(); + }; + }, [desktopBridge]); + + const pairingListTimeTick = useRelativeTimeTick(1_000); + const pairingListNowMs = Date.now(); + const visibleDesktopPairingLinks = useMemo( + () => { + const now = Date.now(); + return desktopPairingLinks.filter( + (pairingLink) => new Date(pairingLink.expiresAt).getTime() > now, + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- pairingListTimeTick forces 1Hz refresh; body uses Date.now(). + [pairingListTimeTick, desktopPairingLinks], + ); + + if (!desktopBridge) { + return ( + + + + + + ); + } + + const otherClientCount = desktopClientSessions.filter((s) => !s.current).length; + + return ( + + + + + {desktopServerExposureState?.mode === "network-accessible" && + desktopServerExposureState.endpointUrl + ? "This environment is reachable over the network." + : desktopServerExposureState + ? "This environment is currently limited to this machine." + : "Loading network access state..."} + + {desktopServerExposureState?.endpointUrl ? ( + + {desktopServerExposureState.endpointUrl} + + ) : null} + {desktopServerExposureError ? ( + {desktopServerExposureError} + ) : null} + + } + control={ + { + if (isUpdatingDesktopServerExposure) { + return; + } + if (!open) { + setPendingDesktopServerExposureMode(null); + } + }} + > + { + setPendingDesktopServerExposureMode( + checked ? "network-accessible" : "local-only", + ); + }} + aria-label="Enable network access" + /> + + + + {pendingDesktopServerExposureMode === "network-accessible" + ? "Enable network access?" + : "Disable network access?"} + + + {pendingDesktopServerExposureMode === "network-accessible" + ? "T3 Code will restart to expose this environment over the network." + : "T3 Code will restart and limit this environment back to this machine."} + + + + } + > + Cancel + + + + + + } + /> + {desktopAccessManagementError} + ) : desktopServerExposureState?.mode === "local-only" ? ( + + Enable network access above to create pairing links. + + ) : desktopServerExposureState?.mode === "network-accessible" && + isLoadingDesktopAccessManagement ? ( + Syncing links… + ) : desktopServerExposureState?.mode === "network-accessible" && + !isLoadingDesktopAccessManagement && + visibleDesktopPairingLinks.length === 0 ? ( + No active pairing links. + ) : null + } + control={ + + } + > + {visibleDesktopPairingLinks.length > 0 ? ( +
    +
      + {visibleDesktopPairingLinks.map((pairingLink) => ( + + ))} +
    +
    + ) : null} +
    + No sessions yet. + ) : ( + + {otherClientCount > 0 + ? `${otherClientCount} other ${otherClientCount === 1 ? "client" : "clients"} can reconnect.` + : "Only this client is connected."} + + ) + } + control={ + + } + > + {desktopClientSessions.length > 0 ? ( +
    +
      + {desktopClientSessions.map((clientSession) => ( + + ))} +
    +
    + ) : null} +
    +
    +
    + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 2876e85114..3edd1f35fb 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1,6 +1,9 @@ import "../../index.css"; import { + type AuthAccessStreamEvent, + type AuthAccessSnapshot, + AuthSessionId, DEFAULT_SERVER_SETTINGS, EnvironmentId, type DesktopBridge, @@ -8,6 +11,7 @@ import { type LocalApi, type ServerConfig, } from "@t3tools/contracts"; +import { DateTime } from "effect"; import { page } from "vitest/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -17,6 +21,116 @@ import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; import { GeneralSettingsPanel } from "./SettingsPanels"; +const authAccessHarness = vi.hoisted(() => { + type Snapshot = AuthAccessSnapshot; + let snapshot: Snapshot = { + pairingLinks: [], + clientSessions: [], + }; + let revision = 1; + const listeners = new Set<(event: AuthAccessStreamEvent) => void>(); + + const emitEvent = (event: AuthAccessStreamEvent) => { + for (const listener of listeners) { + listener(event); + } + }; + + return { + reset() { + snapshot = { + pairingLinks: [], + clientSessions: [], + }; + revision = 1; + listeners.clear(); + }, + setSnapshot(next: Snapshot) { + snapshot = next; + }, + emitSnapshot() { + emitEvent({ + version: 1 as const, + revision, + type: "snapshot" as const, + payload: snapshot, + }); + revision += 1; + }, + emitEvent, + emitPairingLinkUpserted(pairingLink: Snapshot["pairingLinks"][number]) { + emitEvent({ + version: 1, + revision, + type: "pairingLinkUpserted", + payload: pairingLink, + }); + revision += 1; + }, + emitPairingLinkRemoved(id: string) { + emitEvent({ + version: 1, + revision, + type: "pairingLinkRemoved", + payload: { id }, + }); + revision += 1; + }, + emitClientUpserted(clientSession: Snapshot["clientSessions"][number]) { + emitEvent({ + version: 1, + revision, + type: "clientUpserted", + payload: clientSession, + }); + revision += 1; + }, + emitClientRemoved(sessionId: string) { + emitEvent({ + version: 1, + revision, + type: "clientRemoved", + payload: { + sessionId: AuthSessionId.makeUnsafe(sessionId), + }, + }); + revision += 1; + }, + subscribe(listener: (event: AuthAccessStreamEvent) => void) { + listeners.add(listener); + listener({ + version: 1, + revision: 1, + type: "snapshot", + payload: snapshot, + }); + return () => { + listeners.delete(listener); + }; + }, + }; +}); + +vi.mock("../../wsRpcClient", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getPrimaryWsRpcClientEntry: () => + ({ + key: "primary", + knownEnvironment: null, + environmentId: null, + client: { + server: { + subscribeAuthAccess: (listener: Parameters[0]) => + authAccessHarness.subscribe(listener), + }, + }, + }) as unknown as ReturnType, + }; +}); + function createBaseServerConfig(): ServerConfig { return { environment: { @@ -49,6 +163,43 @@ function createBaseServerConfig(): ServerConfig { }; } +function makeUtc(value: string) { + return DateTime.makeUnsafe(Date.parse(value)); +} + +function makePairingLink(input: { + readonly id: string; + readonly credential: string; + readonly role: "owner" | "client"; + readonly subject: string; + readonly createdAt: string; + readonly expiresAt: string; +}): AuthAccessSnapshot["pairingLinks"][number] { + return { + ...input, + createdAt: makeUtc(input.createdAt), + expiresAt: makeUtc(input.expiresAt), + }; +} + +function makeClientSession(input: { + readonly sessionId: string; + readonly subject: string; + readonly role: "owner" | "client"; + readonly method: "browser-session-cookie"; + readonly issuedAt: string; + readonly expiresAt: string; + readonly connected: boolean; + readonly current: boolean; +}): AuthAccessSnapshot["clientSessions"][number] { + return { + ...input, + sessionId: AuthSessionId.makeUnsafe(input.sessionId), + issuedAt: makeUtc(input.issuedAt), + expiresAt: makeUtc(input.expiresAt), + }; +} + const createDesktopBridgeStub = (overrides?: { readonly serverExposureState?: Awaited>; }): DesktopBridge => { @@ -110,11 +261,13 @@ describe("GeneralSettingsPanel observability", () => { resetServerStateForTests(); await __resetLocalApiForTests(); localStorage.clear(); + authAccessHarness.reset(); }); afterEach(async () => { resetServerStateForTests(); await __resetLocalApiForTests(); + authAccessHarness.reset(); }); it("shows diagnostics inside About with a single logs-folder action", async () => { @@ -149,20 +302,71 @@ describe("GeneralSettingsPanel observability", () => { advertisedHost: "192.168.1.44", }, }); + let pairingLinks: Array = []; + let clientSessions: Array = [ + makeClientSession({ + sessionId: "session-owner", + subject: "desktop-bootstrap", + role: "owner", + method: "browser-session-cookie", + issuedAt: "2026-04-07T00:00:00.000Z", + expiresAt: "2026-05-07T00:00:00.000Z", + connected: true, + current: true, + }), + ]; + authAccessHarness.setSnapshot({ + pairingLinks, + clientSessions, + }); vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - credential: "pairing-token", - expiresAt: "2026-04-05T00:00:00.000Z", - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - ), + vi.fn().mockImplementation(async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + if (url.endsWith("/api/auth/pairing-token") && method === "POST") { + pairingLinks = [ + makePairingLink({ + id: "pairing-link-1", + credential: "pairing-token", + role: "client", + subject: "one-time-token", + createdAt: "2026-04-07T00:00:00.000Z", + expiresAt: "2026-04-10T00:05:00.000Z", + }), + ]; + clientSessions = [ + ...clientSessions, + makeClientSession({ + sessionId: "session-client", + subject: "one-time-token", + role: "client", + method: "browser-session-cookie", + issuedAt: "2026-04-07T00:01:00.000Z", + expiresAt: "2026-05-07T00:01:00.000Z", + connected: false, + current: false, + }), + ]; + authAccessHarness.setSnapshot({ + pairingLinks, + clientSessions, + }); + return new Response( + JSON.stringify({ + id: "pairing-link-1", + credential: "pairing-token", + expiresAt: "2026-04-10T00:05:00.000Z", + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + } + + throw new Error(`Unhandled fetch ${method} ${url}`); + }), ); setServerConfigSnapshot(createBaseServerConfig()); @@ -175,11 +379,86 @@ describe("GeneralSettingsPanel observability", () => { await expect.element(page.getByText("Network access")).toBeInTheDocument(); await expect.element(page.getByText("Pair another client")).toBeInTheDocument(); + await expect.element(page.getByText("This client")).toBeInTheDocument(); await page.getByText("Create link", { exact: true }).click(); + authAccessHarness.emitPairingLinkUpserted(pairingLinks[0]!); + authAccessHarness.emitClientUpserted(clientSessions[1]!); await expect .element(page.getByText("http://192.168.1.44:3773/pair?token=pairing-token")) .toBeInTheDocument(); - await expect.element(page.getByText("Copy URL", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("Active pairing links")).toBeInTheDocument(); + await expect.element(page.getByText("Paired clients")).toBeInTheDocument(); + await expect.element(page.getByText("Revoke other clients")).toBeInTheDocument(); + }); + + it("revokes all other paired clients from settings", async () => { + window.desktopBridge = createDesktopBridgeStub({ + serverExposureState: { + mode: "network-accessible", + endpointUrl: "http://192.168.1.44:3773", + advertisedHost: "192.168.1.44", + }, + }); + let clientSessions: Array = [ + makeClientSession({ + sessionId: "session-owner", + subject: "desktop-bootstrap", + role: "owner", + method: "browser-session-cookie", + issuedAt: "2026-04-05T00:00:00.000Z", + expiresAt: "2026-05-05T00:00:00.000Z", + connected: true, + current: true, + }), + makeClientSession({ + sessionId: "session-client", + subject: "one-time-token", + role: "client", + method: "browser-session-cookie", + issuedAt: "2026-04-05T00:01:00.000Z", + expiresAt: "2026-05-05T00:01:00.000Z", + connected: false, + current: false, + }), + ]; + authAccessHarness.setSnapshot({ + pairingLinks: [], + clientSessions, + }); + + const fetchMock = vi.fn().mockImplementation(async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + if (url.endsWith("/api/auth/clients/revoke-others") && method === "POST") { + clientSessions = clientSessions.filter((session) => session.current); + authAccessHarness.setSnapshot({ + pairingLinks: [], + clientSessions, + }); + authAccessHarness.emitClientRemoved("session-client"); + return new Response(JSON.stringify({ revokedCount: 1 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + throw new Error(`Unhandled fetch ${method} ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + setServerConfigSnapshot(createBaseServerConfig()); + + await render( + + + , + ); + + await expect.element(page.getByText("Client session")).toBeInTheDocument(); + await page.getByText("Revoke other clients", { exact: true }).click(); + await expect.element(page.getByText("This client")).toBeInTheDocument(); + await expect.element(page.getByText("Client session")).not.toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalled(); }); it("confirms before restarting to change network access", async () => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4f9fd28688..43a56a7c08 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -6,14 +6,12 @@ import { LoaderIcon, PlusIcon, RefreshCwIcon, - Undo2Icon, XIcon, } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, - type DesktopServerExposureState, type ScopedThreadRef, type ProviderKind, type ServerProvider, @@ -24,7 +22,6 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; -import { createServerPairingCredential } from "../../authBootstrap"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -36,7 +33,6 @@ import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { isElectron } from "../../env"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -58,23 +54,20 @@ import { import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; -import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "../ui/alert-dialog"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { Spinner } from "../ui/spinner"; import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; import { ProjectFavicon } from "../ProjectFavicon"; import { useServerAvailableEditors, @@ -204,15 +197,6 @@ function getProviderVersionLabel(version: string | null | undefined) { return version.startsWith("v") ? version : `v${version}`; } -function useRelativeTimeTick(intervalMs = 1_000) { - const [tick, setTick] = useState(() => Date.now()); - useEffect(() => { - const id = setInterval(() => setTick(Date.now()), intervalMs); - return () => clearInterval(id); - }, [intervalMs]); - return tick; -} - function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { useRelativeTimeTick(); const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; @@ -235,104 +219,6 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } ); } -function SettingsSection({ - title, - icon, - headerAction, - children, -}: { - title: string; - icon?: ReactNode; - headerAction?: ReactNode; - children: ReactNode; -}) { - return ( -
    -
    -

    - {icon} - {title} -

    - {headerAction} -
    -
    - {children} -
    -
    - ); -} - -function SettingsRow({ - title, - description, - status, - resetAction, - control, - children, -}: { - title: ReactNode; - description: string; - status?: ReactNode; - resetAction?: ReactNode; - control?: ReactNode; - children?: ReactNode; -}) { - return ( -
    -
    -
    -
    -

    {title}

    - - {resetAction} - -
    -

    {description}

    - {status ?
    {status}
    : null} -
    - {control ? ( -
    - {control} -
    - ) : null} -
    - {children} -
    - ); -} - -function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { - return ( - - { - event.stopPropagation(); - onClick(); - }} - > - - - } - /> - Reset to default - - ); -} - -function SettingsPageContainer({ children }: { children: ReactNode }) { - return ( -
    -
    {children}
    -
    - ); -} - function AboutVersionTitle() { return ( @@ -460,13 +346,6 @@ function AboutVersionSection() { ); } -function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { - const url = new URL(endpointUrl); - url.pathname = "/pair"; - url.searchParams.set("token", credential); - return url.toString(); -} - export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); const settings = useSettings(); @@ -801,120 +680,6 @@ export function GeneralSettingsPanel() { serverProviders[0]!.checkedAt, ) : null; - const [desktopServerExposureState, setDesktopServerExposureState] = - useState(null); - const [desktopServerExposureError, setDesktopServerExposureError] = useState(null); - const [isUpdatingDesktopServerExposure, setIsUpdatingDesktopServerExposure] = useState(false); - const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState< - DesktopServerExposureState["mode"] | null - >(null); - const [desktopPairingUrl, setDesktopPairingUrl] = useState(null); - const [isCreatingDesktopPairingUrl, setIsCreatingDesktopPairingUrl] = useState(false); - const desktopBridge = window.desktopBridge; - const { copyToClipboard: copyDesktopPairingUrl, isCopied: isDesktopPairingUrlCopied } = - useCopyToClipboard({ - onCopy: () => { - toastManager.add({ - type: "success", - title: "Pairing URL copied", - description: "Open it in the client you want to pair to this environment.", - }); - }, - onError: (error) => { - toastManager.add({ - type: "error", - title: "Could not copy pairing URL", - description: error.message, - }); - }, - }); - const handleCopyDesktopPairingUrl = useCallback(() => { - if (!desktopPairingUrl) return; - copyDesktopPairingUrl(desktopPairingUrl, undefined); - }, [copyDesktopPairingUrl, desktopPairingUrl]); - - const createDesktopPairingUrl = useCallback(async (endpointUrl: string) => { - setIsCreatingDesktopPairingUrl(true); - setDesktopServerExposureError(null); - try { - const result = await createServerPairingCredential(); - setDesktopPairingUrl(resolveDesktopPairingUrl(endpointUrl, result.credential)); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create pairing URL."; - setDesktopServerExposureError(message); - toastManager.add({ - type: "error", - title: "Could not create pairing URL", - description: message, - }); - } finally { - setIsCreatingDesktopPairingUrl(false); - } - }, []); - - const handleDesktopServerExposureChange = useCallback( - async (checked: boolean) => { - if (!desktopBridge) return; - - setIsUpdatingDesktopServerExposure(true); - setDesktopServerExposureError(null); - try { - if (!checked) { - setDesktopPairingUrl(null); - } - const nextState = await desktopBridge.setServerExposureMode( - checked ? "network-accessible" : "local-only", - ); - setDesktopServerExposureState(nextState); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to update network exposure."; - setPendingDesktopServerExposureMode(null); - setDesktopServerExposureError(message); - toastManager.add({ - type: "error", - title: "Could not update network access", - description: message, - }); - setIsUpdatingDesktopServerExposure(false); - } - }, - [desktopBridge], - ); - - const handleConfirmDesktopServerExposureChange = useCallback(() => { - if (pendingDesktopServerExposureMode === null) return; - const checked = pendingDesktopServerExposureMode === "network-accessible"; - void handleDesktopServerExposureChange(checked); - }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]); - - const handleCreateDesktopPairingUrl = useCallback(() => { - const endpointUrl = desktopServerExposureState?.endpointUrl; - if (!endpointUrl) return; - void createDesktopPairingUrl(endpointUrl); - }, [createDesktopPairingUrl, desktopServerExposureState?.endpointUrl]); - - useEffect(() => { - if (!desktopBridge) return; - - let cancelled = false; - void desktopBridge - .getServerExposureState() - .then((state) => { - if (cancelled) return; - setDesktopServerExposureState(state); - }) - .catch((error: unknown) => { - if (cancelled) return; - const message = - error instanceof Error ? error.message : "Failed to load network exposure state."; - setDesktopServerExposureError(message); - }); - - return () => { - cancelled = true; - }; - }, [desktopBridge]); return ( @@ -1580,141 +1345,6 @@ export function GeneralSettingsPanel() { /> - {desktopBridge ? ( - - - - {desktopServerExposureState?.mode === "network-accessible" && - desktopServerExposureState.endpointUrl - ? "This environment is reachable over the network." - : desktopServerExposureState - ? "This environment is currently limited to this machine." - : "Loading network access state..."} - - {desktopServerExposureState?.endpointUrl ? ( - - {desktopServerExposureState.endpointUrl} - - ) : null} - {desktopServerExposureError ? ( - {desktopServerExposureError} - ) : null} - - } - control={ - { - if (isUpdatingDesktopServerExposure) { - return; - } - if (!open) { - setPendingDesktopServerExposureMode(null); - } - }} - > - { - setPendingDesktopServerExposureMode( - checked ? "network-accessible" : "local-only", - ); - }} - aria-label="Enable network access" - /> - - - - {pendingDesktopServerExposureMode === "network-accessible" - ? "Enable network access?" - : "Disable network access?"} - - - {pendingDesktopServerExposureMode === "network-accessible" - ? "T3 Code will restart to expose this environment over the network." - : "T3 Code will restart and limit this environment back to this machine."} - - - - - } - > - Cancel - - - - - - } - /> - - {desktopPairingUrl} - - ) : desktopServerExposureState?.mode === "local-only" ? ( - Enable network access before creating a pairing link. - ) : null - } - control={ -
    - - -
    - } - /> - - ) : null} - {isElectron ? ( diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6ba698c91f..0bf9f5d2f0 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -1,5 +1,5 @@ import type { ComponentType } from "react"; -import { ArchiveIcon, ArrowLeftIcon, Settings2Icon } from "lucide-react"; +import { ArchiveIcon, ArrowLeftIcon, Link2Icon, Settings2Icon } from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; import { @@ -12,7 +12,10 @@ import { SidebarSeparator, } from "../ui/sidebar"; -export type SettingsSectionPath = "/settings/general" | "/settings/archived"; +export type SettingsSectionPath = + | "/settings/general" + | "/settings/connections" + | "/settings/archived"; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ label: string; @@ -20,6 +23,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx new file mode 100644 index 0000000000..3117b72464 --- /dev/null +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -0,0 +1,119 @@ +import { Undo2Icon } from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; + +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +/** Re-render every `intervalMs`; return value is a counter (compare time with `Date.now()` in render). */ +export function useRelativeTimeTick(intervalMs = 1_000) { + const [tick, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((n) => n + 1), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return tick; +} + +export function SettingsSection({ + title, + icon, + headerAction, + children, +}: { + title: string; + icon?: ReactNode; + headerAction?: ReactNode; + children: ReactNode; +}) { + return ( +
    +
    +

    + {icon} + {title} +

    + {headerAction} +
    +
    + {children} +
    +
    + ); +} + +export function SettingsRow({ + title, + description, + status, + resetAction, + control, + children, +}: { + title: ReactNode; + description: string; + status?: ReactNode; + resetAction?: ReactNode; + control?: ReactNode; + children?: ReactNode; +}) { + return ( +
    +
    +
    +
    +

    {title}

    + + {resetAction} + +
    +

    {description}

    + {status ?
    {status}
    : null} +
    + {control ? ( +
    + {control} +
    + ) : null} +
    + {children} +
    + ); +} + +export function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + > + + + } + /> + Reset to default + + ); +} + +export function SettingsPageContainer({ children }: { children: ReactNode }) { + return ( +
    +
    {children}
    +
    + ); +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index cff769cb88..03a20ec01d 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -79,6 +79,7 @@ const rpcClientMock = { updateSettings: vi.fn(), subscribeConfig: vi.fn(), subscribeLifecycle: vi.fn(), + subscribeAuthAccess: vi.fn(), }, orchestration: { getSnapshot: vi.fn(), diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 9ffdb142a5..6494ecdb25 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' +import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -42,6 +43,11 @@ const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ path: '/general', getParentRoute: () => SettingsRoute, } as any) +const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ + id: '/connections', + path: '/connections', + getParentRoute: () => SettingsRoute, +} as any) const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', @@ -64,6 +70,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -72,6 +79,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -83,6 +91,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -95,6 +104,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/connections' | '/settings/general' | '/$environmentId/$threadId' | '/draft/$draftId' @@ -103,6 +113,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/connections' | '/settings/general' | '/' | '/$environmentId/$threadId' @@ -113,6 +124,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/connections' | '/settings/general' | '/_chat/' | '/_chat/$environmentId/$threadId' @@ -162,6 +174,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsGeneralRouteImport parentRoute: typeof SettingsRoute } + '/settings/connections': { + id: '/settings/connections' + path: '/connections' + fullPath: '/settings/connections' + preLoaderRoute: typeof SettingsConnectionsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/archived': { id: '/settings/archived' path: '/archived' @@ -202,11 +221,13 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute + SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute } const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, + SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsGeneralRoute: SettingsGeneralRoute, } diff --git a/apps/web/src/routes/settings.connections.tsx b/apps/web/src/routes/settings.connections.tsx new file mode 100644 index 0000000000..275eda6c51 --- /dev/null +++ b/apps/web/src/routes/settings.connections.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ConnectionsSettings } from "../components/settings/ConnectionsSettings"; + +export const Route = createFileRoute("/settings/connections")({ + component: ConnectionsSettings, +}); diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index f45ada7341..175c16242e 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getTimestampFormatOptions } from "./timestampFormat"; +import { + formatExpiresInLabel, + formatRelativeTimeUntilLabel, + getTimestampFormatOptions, +} from "./timestampFormat"; describe("getTimestampFormatOptions", () => { it("omits hour12 when locale formatting is requested", () => { @@ -28,3 +32,59 @@ describe("getTimestampFormatOptions", () => { }); }); }); + +describe("formatRelativeTimeUntilLabel", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns Expired when the instant is in the past", () => { + expect(formatRelativeTimeUntilLabel("2026-04-07T11:59:00.000Z")).toBe("Expired"); + }); + + it("formats seconds remaining", () => { + expect(formatRelativeTimeUntilLabel("2026-04-07T12:00:45.000Z")).toBe("45s left"); + }); + + it("formats minutes remaining", () => { + expect(formatRelativeTimeUntilLabel("2026-04-07T12:15:00.000Z")).toBe("15m left"); + }); + + it("formats hours remaining", () => { + expect(formatRelativeTimeUntilLabel("2026-04-07T18:00:00.000Z")).toBe("6h left"); + }); +}); + +describe("formatExpiresInLabel", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns Expired when the instant is in the past", () => { + expect(formatExpiresInLabel("2026-04-07T11:59:00.000Z")).toBe("Expired"); + }); + + it("uses sub-minute second count", () => { + expect(formatExpiresInLabel("2026-04-07T12:00:45.000Z")).toBe("Expires in 45s"); + }); + + it("uses minutes and seconds under one hour", () => { + expect(formatExpiresInLabel("2026-04-07T12:04:12.000Z")).toBe("Expires in 4m 12s"); + expect(formatExpiresInLabel("2026-04-07T12:15:00.000Z")).toBe("Expires in 15m"); + }); + + it("uses hours with minute and second remainder", () => { + expect(formatExpiresInLabel("2026-04-07T14:02:03.000Z")).toBe("Expires in 2h 2m 3s"); + expect(formatExpiresInLabel("2026-04-07T18:00:00.000Z")).toBe("Expires in 6h"); + }); +}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 453c070166..c1d6f69fbf 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -71,3 +71,68 @@ export function formatRelativeTimeLabel(isoDate: string) { const relative = formatRelativeTime(isoDate); return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } + +/** + * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times. + */ +export function formatRelativeTimeUntil(isoDate: string): { value: string; suffix: string | null } { + const diffMs = new Date(isoDate).getTime() - Date.now(); + if (diffMs <= 0) return { value: "Expired", suffix: null }; + const seconds = Math.floor(diffMs / 1000); + if (seconds < 5) return { value: "Soon", suffix: null }; + if (seconds < 60) return { value: `${seconds}s`, suffix: "left" }; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return { value: `${minutes}m`, suffix: "left" }; + const hours = Math.floor(minutes / 60); + if (hours < 24) return { value: `${hours}h`, suffix: "left" }; + const days = Math.floor(hours / 24); + return { value: `${days}d`, suffix: "left" }; +} + +export function formatRelativeTimeUntilLabel(isoDate: string): string { + const relative = formatRelativeTimeUntil(isoDate); + return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; +} + +/** + * Countdown for a future instant (e.g. link expiry): "Expires in 4m 12s", with second precision under one hour. + * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. + */ +export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string { + const diffMs = new Date(isoDate).getTime() - nowMs; + if (diffMs <= 0) return "Expired"; + + const totalSeconds = Math.floor(diffMs / 1000); + if (totalSeconds < 5) return "Expires in a moment"; + if (totalSeconds < 60) return `Expires in ${totalSeconds}s`; + + if (totalSeconds < 3600) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return seconds === 0 ? `Expires in ${minutes}m` : `Expires in ${minutes}m ${seconds}s`; + } + + if (totalSeconds < 86_400) { + const hours = Math.floor(totalSeconds / 3600); + const rem = totalSeconds % 3600; + const minutes = Math.floor(rem / 60); + const seconds = rem % 60; + const parts = [`${hours}h`]; + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0) parts.push(`${seconds}s`); + return `Expires in ${parts.join(" ")}`; + } + + const days = Math.floor(totalSeconds / 86_400); + const remAfterDays = totalSeconds % 86_400; + if (remAfterDays === 0) return `Expires in ${days}d`; + const hours = Math.floor(remAfterDays / 3600); + const rem = remAfterDays % 3600; + const minutes = Math.floor(rem / 60); + const seconds = rem % 60; + const tail: string[] = []; + if (hours > 0) tail.push(`${hours}h`); + if (minutes > 0) tail.push(`${minutes}m`); + if (seconds > 0) tail.push(`${seconds}s`); + return tail.length > 0 ? `Expires in ${days}d ${tail.join(" ")}` : `Expires in ${days}d`; +} diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 7251b91fab..81e16934cf 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -104,6 +104,7 @@ export interface WsRpcClient { ) => ReturnType>; readonly subscribeConfig: RpcStreamMethod; readonly subscribeLifecycle: RpcStreamMethod; + readonly subscribeAuthAccess: RpcStreamMethod; }; readonly orchestration: { readonly getSnapshot: RpcUnaryNoArgMethod; @@ -366,6 +367,12 @@ export function createWsRpcClient( listener, options, ), + subscribeAuthAccess: (listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeAuthAccess]({}), + listener, + options, + ), }, orchestration: { getSnapshot: () => diff --git a/bun.lock b/bun.lock index 74c5badbe6..9e724784e2 100644 --- a/bun.lock +++ b/bun.lock @@ -95,6 +95,7 @@ "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", @@ -1484,6 +1485,8 @@ "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="], + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 0212fe89db..e8e7f362af 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas"; /** * Declares the server's overall authentication posture. @@ -69,6 +69,9 @@ export const ServerAuthSessionMethod = Schema.Literals([ ]); export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; +export const AuthSessionRole = Schema.Literals(["owner", "client"]); +export type AuthSessionRole = typeof AuthSessionRole.Type; + /** * Server-advertised auth capabilities for a specific execution environment. * @@ -104,20 +107,116 @@ export type AuthBootstrapInput = typeof AuthBootstrapInput.Type; export const AuthBootstrapResult = Schema.Struct({ authenticated: Schema.Literal(true), + role: AuthSessionRole, sessionMethod: ServerAuthSessionMethod, expiresAt: Schema.DateTimeUtc, }); export type AuthBootstrapResult = typeof AuthBootstrapResult.Type; export const AuthPairingCredentialResult = Schema.Struct({ + id: TrimmedNonEmptyString, credential: TrimmedNonEmptyString, expiresAt: Schema.DateTimeUtc, }); export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Type; +export const AuthPairingLink = Schema.Struct({ + id: TrimmedNonEmptyString, + credential: TrimmedNonEmptyString, + role: AuthSessionRole, + subject: TrimmedNonEmptyString, + createdAt: Schema.DateTimeUtc, + expiresAt: Schema.DateTimeUtc, +}); +export type AuthPairingLink = typeof AuthPairingLink.Type; + +export const AuthClientSession = Schema.Struct({ + sessionId: AuthSessionId, + subject: TrimmedNonEmptyString, + role: AuthSessionRole, + method: ServerAuthSessionMethod, + issuedAt: Schema.DateTimeUtc, + expiresAt: Schema.DateTimeUtc, + connected: Schema.Boolean, + current: Schema.Boolean, +}); +export type AuthClientSession = typeof AuthClientSession.Type; + +export const AuthAccessSnapshot = Schema.Struct({ + pairingLinks: Schema.Array(AuthPairingLink), + clientSessions: Schema.Array(AuthClientSession), +}); +export type AuthAccessSnapshot = typeof AuthAccessSnapshot.Type; + +export const AuthAccessStreamSnapshotEvent = Schema.Struct({ + version: Schema.Literal(1), + revision: Schema.Number, + type: Schema.Literal("snapshot"), + payload: AuthAccessSnapshot, +}); +export type AuthAccessStreamSnapshotEvent = typeof AuthAccessStreamSnapshotEvent.Type; + +export const AuthAccessStreamPairingLinkUpsertedEvent = Schema.Struct({ + version: Schema.Literal(1), + revision: Schema.Number, + type: Schema.Literal("pairingLinkUpserted"), + payload: AuthPairingLink, +}); +export type AuthAccessStreamPairingLinkUpsertedEvent = + typeof AuthAccessStreamPairingLinkUpsertedEvent.Type; + +export const AuthAccessStreamPairingLinkRemovedEvent = Schema.Struct({ + version: Schema.Literal(1), + revision: Schema.Number, + type: Schema.Literal("pairingLinkRemoved"), + payload: Schema.Struct({ + id: TrimmedNonEmptyString, + }), +}); +export type AuthAccessStreamPairingLinkRemovedEvent = + typeof AuthAccessStreamPairingLinkRemovedEvent.Type; + +export const AuthAccessStreamClientUpsertedEvent = Schema.Struct({ + version: Schema.Literal(1), + revision: Schema.Number, + type: Schema.Literal("clientUpserted"), + payload: AuthClientSession, +}); +export type AuthAccessStreamClientUpsertedEvent = typeof AuthAccessStreamClientUpsertedEvent.Type; + +export const AuthAccessStreamClientRemovedEvent = Schema.Struct({ + version: Schema.Literal(1), + revision: Schema.Number, + type: Schema.Literal("clientRemoved"), + payload: Schema.Struct({ + sessionId: AuthSessionId, + }), +}); +export type AuthAccessStreamClientRemovedEvent = typeof AuthAccessStreamClientRemovedEvent.Type; + +export const AuthAccessStreamEvent = Schema.Union([ + AuthAccessStreamSnapshotEvent, + AuthAccessStreamPairingLinkUpsertedEvent, + AuthAccessStreamPairingLinkRemovedEvent, + AuthAccessStreamClientUpsertedEvent, + AuthAccessStreamClientRemovedEvent, +]); +export type AuthAccessStreamEvent = typeof AuthAccessStreamEvent.Type; + +export const AuthRevokePairingLinkInput = Schema.Struct({ + id: TrimmedNonEmptyString, +}); +export type AuthRevokePairingLinkInput = typeof AuthRevokePairingLinkInput.Type; + +export const AuthRevokeClientSessionInput = Schema.Struct({ + sessionId: AuthSessionId, +}); +export type AuthRevokeClientSessionInput = typeof AuthRevokeClientSessionInput.Type; + export const AuthSessionState = Schema.Struct({ authenticated: Schema.Boolean, auth: ServerAuthDescriptor, + role: Schema.optionalKey(AuthSessionRole), sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), expiresAt: Schema.optionalKey(Schema.DateTimeUtc), }); diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 5a199e9a67..9178ef8da7 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -29,6 +29,8 @@ export const MessageId = makeEntityId("MessageId"); export type MessageId = typeof MessageId.Type; export const TurnId = makeEntityId("TurnId"); export type TurnId = typeof TurnId.Type; +export const AuthSessionId = makeEntityId("AuthSessionId"); +export type AuthSessionId = typeof AuthSessionId.Type; export const ProviderItemId = makeEntityId("ProviderItemId"); export type ProviderItemId = typeof ProviderItemId.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a3d10299df..f47b427bcd 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -3,6 +3,7 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { OpenError, OpenInEditorInput } from "./editor"; +import { AuthAccessStreamEvent } from "./auth"; import { GitActionProgressEvent, GitCheckoutInput, @@ -118,6 +119,7 @@ export const WS_METHODS = { subscribeTerminalEvents: "subscribeTerminalEvents", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", + subscribeAuthAccess: "subscribeAuthAccess", } as const; export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -334,6 +336,12 @@ export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServer stream: true, }); +export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { + payload: Schema.Struct({}), + success: AuthAccessStreamEvent, + stream: true, +}); + export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, @@ -365,6 +373,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeTerminalEventsRpc, WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, + WsSubscribeAuthAccessRpc, WsOrchestrationGetSnapshotRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetTurnDiffRpc,